int)`. * The Phase-8 route registration layer * ({@see \Affilun\Presentation\Rest\SettingsServiceProvider}) * 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. * * Exception mapping: * * - Any {@see \Throwable} surfacing out of the use case is * translated to a §4.3 500 `affilun_internal_error` envelope * with the literal message `'Internal server error.'`. * The original exception's class and message are NOT * forwarded so the §13.1 "no internal detail leakage" * posture is preserved (per * `specifications/security-and-compliance.md` §6.4). * * ### 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 capability authorization (§3.2 / §3.3) * via the injected {@see CapabilityPolicy} + * capability provider; nonce remains the WP framework's * responsibility. * * @package Affilun\Presentation\Rest\Controller */ declare(strict_types=1); namespace Affilun\Presentation\Rest\Controller; use Affilun\Application\Ai\AiSuggestionException; use Affilun\Application\Ai\CloudArticleSuggestionProvider; use Affilun\Application\Cloud\CloudHttpOptions; use Affilun\Application\Settings\CloudSettings; use Affilun\Application\Settings\SettingsRepository; use Affilun\Application\Settings\UseCase\GetPublicSettingsUseCase; use Affilun\Domain\Provider\Exception\ProviderException; use Affilun\Presentation\Rest\Mapper\PublicSettingsMapper; 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 RuntimeException; use Throwable; /** * REST controller for the §13.1 `GET /affilun/v1/settings/public` * endpoint. * * See the file-level docblock for the §13.1 public-safe data * contract, the §3.2 capability policy, the synthetic * {@see self::META_HTTP_STATUS} envelope key, and the * exception mapping posture. */ final class SettingsController { /** * Stable route token for §13.1 * `GET /affilun/v1/settings/public`. */ public const ROUTE_TOKEN_PUBLIC_GET = 'settings.read.public'; /** * Stable route token for `POST /affilun/v1/settings/ai/extract-keywords`. */ public const ROUTE_TOKEN_AI_EXTRACT_KEYWORDS = 'settings.ai.extract_keywords'; /** * Stable route token for `GET /affilun/v1/settings/cloud/plans`. */ public const ROUTE_TOKEN_CLOUD_PLANS = 'settings.cloud.plans'; /** * Stable route token for `POST /affilun/v1/settings/cloud/credentials`. */ public const ROUTE_TOKEN_CLOUD_APPLY_CREDENTIALS = 'settings.cloud.apply_credentials'; /** * Stable route token for `POST /affilun/v1/settings/cloud/checkout-session`. */ public const ROUTE_TOKEN_CLOUD_CHECKOUT_SESSION = 'settings.cloud.checkout_session'; /** * Stable route token for `POST /affilun/v1/settings/cloud/provision-site`. */ public const ROUTE_TOKEN_CLOUD_PROVISION_SITE = 'settings.cloud.provision_site'; /** * Stable route token for `POST /affilun/v1/settings/cloud/connection-email`. */ public const ROUTE_TOKEN_CLOUD_CONNECTION_EMAIL = 'settings.cloud.connection_email'; /** * Stable route token for `POST /affilun/v1/settings/cloud/status`. */ public const ROUTE_TOKEN_CLOUD_STATUS = 'settings.cloud.status'; /** * Stable route token for `POST /affilun/v1/settings/cloud/connection-token`. */ public const ROUTE_TOKEN_CLOUD_CONNECTION_TOKEN = 'settings.cloud.connection_token'; /** * Synthetic envelope key carrying the desired HTTP status. * * The Phase-8 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'; /** * §13.1 public-settings use case. */ private GetPublicSettingsUseCase $getPublicSettingsUseCase; /** * §13.1 settings → public response mapper. */ private PublicSettingsMapper $publicSettingsMapper; /** * Full settings repository used by admin-only Cloud onboarding and AI endpoints. */ private ?SettingsRepository $settingsRepository; /** * Optional affilun Cloud AI transport override. * * @var callable|null */ private $cloudAiTransport = null; /** * §15.2 provider exception → error envelope mapper. * * Held for symmetry with the other Phase-6 / Phase-7 * controllers; the §13.1 endpoint never invokes a provider * directly, but a future slice may extend the use case to * resolve provider health, and the {@see ProviderException} * branch in {@see self::mapException()} routes through this * mapper so the wiring seam stays uniform. */ private ProviderExceptionMapper $exceptionMapper; /** * §3.2 / §3.3 capability policy. */ private CapabilityPolicy $capabilityPolicy; /** * §17.1 request input validator. * * Held for parity with the other REST controllers; the * §13.1 endpoint has no body and no path params, so the * validator is unused on the current code path. A future * slice that adds query-string filters can read it without * widening the constructor signature. * * @phpstan-ignore property.onlyWritten */ 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\EditorController} * 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 GetPublicSettingsUseCase $getPublicSettingsUseCase §13.1 use case. * @param PublicSettingsMapper $publicSettingsMapper §13.1 response mapper. * @param ProviderExceptionMapper $exceptionMapper §15.2 mapper (held for parity). * @param CapabilityPolicy $capabilityPolicy §3.2 / §3.3 policy. * @param RequestValidator $requestValidator §17.1 validator (held for parity). * @param RestRequestAdapter $requestAdapter §3 request adapter. * @param mixed $settingsRepositoryOrUserIdProvider Repository or legacy user resolver. * @param callable|null $capabilityProvider Capability resolver. * @param SettingsRepository|null $settingsRepository Repository for the legacy call pattern. * @param callable|null $cloudAiTransport Optional Cloud AI transport. * * @phpstan-param SettingsRepository|callable|null $settingsRepositoryOrUserIdProvider */ public function __construct( GetPublicSettingsUseCase $getPublicSettingsUseCase, PublicSettingsMapper $publicSettingsMapper, ProviderExceptionMapper $exceptionMapper, CapabilityPolicy $capabilityPolicy, RequestValidator $requestValidator, RestRequestAdapter $requestAdapter, SettingsRepository|callable|null $settingsRepositoryOrUserIdProvider = null, ?callable $capabilityProvider = null, ?SettingsRepository $settingsRepository = null, ?callable $cloudAiTransport = null ) { $this->getPublicSettingsUseCase = $getPublicSettingsUseCase; $this->publicSettingsMapper = $publicSettingsMapper; $this->exceptionMapper = $exceptionMapper; $this->capabilityPolicy = $capabilityPolicy; $this->requestValidator = $requestValidator; $this->requestAdapter = $requestAdapter; $userIdProvider = null; if ( $settingsRepositoryOrUserIdProvider instanceof SettingsRepository ) { $this->settingsRepository = $settingsRepositoryOrUserIdProvider; } else { $this->settingsRepository = $settingsRepository; $userIdProvider = $settingsRepositoryOrUserIdProvider; } $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->cloudAiTransport = $cloudAiTransport; $this->capabilityProvider = $capabilityProvider ?? static function ( string $cap ): bool { if ( ! function_exists( 'current_user_can' ) ) { return false; } return (bool) current_user_can( $cap ); }; } /** * §13.1 `GET /affilun/v1/settings/public` dispatch. * * No request body, no path params. Drives * {@see GetPublicSettingsUseCase}, runs the result through * {@see PublicSettingsMapper}, and returns the §4 success * envelope. * * @param mixed $request WP_REST_Request, or array shim. * * @return array §13.1 envelope + synthetic * {@see self::META_HTTP_STATUS}. */ public function readPublic( mixed $request ): array { $required = $this->capabilityPolicy::requiredForRead( self::ROUTE_TOKEN_PUBLIC_GET ); $ctx = $this->buildContext( $request, $required ); if ( ! $this->isAuthorized( $ctx, $required ) ) { return $this->errorResponse( ErrorEnvelope::CODE_FORBIDDEN, 'Insufficient capabilities.', ErrorEnvelope::STATUS_FORBIDDEN, $ctx ); } try { $settings = $this->getPublicSettingsUseCase->execute(); $data = $this->publicSettingsMapper->map( $settings ); } catch ( Throwable $e ) { return $this->mapException( $e, $ctx ); } $envelope = SuccessEnvelope::fromContext( $data, $ctx ); return $this->successResponse( $envelope, 200 ); } /** * `GET /affilun/v1/settings/cloud/plans` dispatch. * * Returns plugin-onboarding plan metadata without exposing any affilun Cloud * API key material. Pricing remains explicitly checkout-confirmed until * the AFFILUN API exposes server-controlled monthly/yearly plan prices. * * @param mixed $request WP_REST_Request, or array shim. * * @return array §4.1 envelope + synthetic status. */ public function cloudPlans( mixed $request ): array { $required = $this->capabilityPolicy::requiredForRead( self::ROUTE_TOKEN_CLOUD_PLANS ); $ctx = $this->buildContext( $request, $required ); if ( ! $this->isAuthorized( $ctx, $required ) ) { return $this->errorResponse( ErrorEnvelope::CODE_FORBIDDEN, 'Insufficient capabilities.', ErrorEnvelope::STATUS_FORBIDDEN, $ctx ); } $envelope = SuccessEnvelope::fromContext( $this->cloudPlanCatalog(), $ctx ); return $this->successResponse( $envelope, 200 ); } /** * `POST /affilun/v1/settings/cloud/credentials` dispatch. * * Persists the one-time site id / API key secret returned by affilun Cloud after * checkout and site provisioning. This endpoint is the plugin-side seam that * lets the browser onboarding flow avoid asking the user to copy/paste the * API key into a visible settings field. * * @param mixed $request WP_REST_Request, or array shim. * * @return array §4.1 envelope + synthetic status. */ public function applyCloudCredentials( mixed $request ): array { $required = $this->capabilityPolicy::requiredForWrite( self::ROUTE_TOKEN_CLOUD_APPLY_CREDENTIALS ); $ctx = $this->buildContext( $request, $required ); if ( ! $this->isAuthorized( $ctx, $required ) ) { return $this->errorResponse( ErrorEnvelope::CODE_FORBIDDEN, 'Insufficient capabilities.', ErrorEnvelope::STATUS_FORBIDDEN, $ctx ); } if ( $this->settingsRepository === null ) { return $this->errorResponse( ErrorEnvelope::CODE_INTERNAL_ERROR, 'Settings repository unavailable.', ErrorEnvelope::STATUS_INTERNAL_SERVER_ERROR, $ctx ); } $body = $this->requestAdapter->jsonBody( $request ); $siteId = self::stringFromArray( $body, 'site_id' ); $apiKey = self::stringFromArray( $body, 'api_key' ); $endpoint = self::stringFromArray( $body, 'endpoint' ) ?: CloudSettings::DEFAULT_ENDPOINT; $plan = self::stringFromArray( $body, 'plan' ) ?: 'personal'; if ( $siteId === '' || $apiKey === '' ) { return $this->errorResponse( ErrorEnvelope::CODE_VALIDATION_FAILED, 'affilun Cloud site id and API key are required.', ErrorEnvelope::STATUS_UNPROCESSABLE_ENTITY, $ctx, array( 'fields' => array( 'site_id' => $siteId === '' ? 'required' : 'ok', 'api_key' => $apiKey === '' ? 'required' : 'ok', ), ) ); } if ( ! in_array( $plan, array( 'personal', 'growth', 'agency' ), true ) ) { $plan = 'personal'; } try { $cloud = new CloudSettings( true, $endpoint, $siteId, $apiKey, $plan ); $settings = $this->settingsRepository->load()->withCloud( $cloud ); $this->settingsRepository->save( $settings ); } catch ( Throwable $e ) { return $this->mapException( $e, $ctx ); } $envelope = SuccessEnvelope::fromContext( array( 'cloud' => array( 'enabled' => $cloud->enabled(), 'configured' => $cloud->isConfigured(), 'endpoint' => $cloud->endpoint(), 'site_id' => $cloud->siteId(), 'api_key_masked' => $cloud->maskedApiKey(), 'plan' => $cloud->plan(), ), ), $ctx ); return $this->successResponse( $envelope, 200 ); } /** * `POST /affilun/v1/settings/cloud/checkout-session` dispatch. * * Starts a Stripe Checkout Session through the configured affilun Cloud API. * * @param mixed $request WP_REST_Request, or array shim. * * @return array §4.1 envelope + synthetic status. */ public function createCloudCheckoutSession( mixed $request ): array { $required = $this->capabilityPolicy::requiredForWrite( self::ROUTE_TOKEN_CLOUD_CHECKOUT_SESSION ); $ctx = $this->buildContext( $request, $required ); if ( ! $this->isAuthorized( $ctx, $required ) ) { return $this->errorResponse( ErrorEnvelope::CODE_FORBIDDEN, 'Insufficient capabilities.', ErrorEnvelope::STATUS_FORBIDDEN, $ctx ); } if ( $this->settingsRepository === null ) { return $this->errorResponse( ErrorEnvelope::CODE_INTERNAL_ERROR, 'Settings repository unavailable.', ErrorEnvelope::STATUS_INTERNAL_SERVER_ERROR, $ctx ); } $body = $this->requestAdapter->jsonBody( $request ); $email = self::stringFromArray( $body, 'email' ); $siteUrl = self::stringFromArray( $body, 'site_url' ); $siteName = self::stringFromArray( $body, 'site_name' ); $plan = self::stringFromArray( $body, 'plan' ) ?: 'personal'; $period = self::stringFromArray( $body, 'billing_period' ) ?: 'monthly'; $successUrl = self::stringFromArray( $body, 'success_url' ); $cancelUrl = self::stringFromArray( $body, 'cancel_url' ); if ( $email === '' || $siteUrl === '' || $siteName === '' ) { return $this->errorResponse( ErrorEnvelope::CODE_VALIDATION_FAILED, 'affilun Cloud checkout requires email, site URL, and site name.', ErrorEnvelope::STATUS_UNPROCESSABLE_ENTITY, $ctx ); } if ( ! in_array( $plan, array( 'personal', 'growth', 'agency' ), true ) ) { $plan = 'personal'; } if ( ! in_array( $period, array( 'monthly', 'yearly' ), true ) ) { $period = 'monthly'; } try { $endpoint = $this->settingsRepository->load()->cloud()->endpoint(); $response = CloudHttpOptions::safePost( $endpoint . '/billing/plugin/checkout-session', array( 'timeout' => 5, 'headers' => array( 'Accept' => 'application/json', 'Content-Type' => 'application/json', ), 'body' => wp_json_encode( array_filter( array( 'email' => $email, 'site_url' => $siteUrl, 'site_name' => $siteName, 'plan' => $plan, 'billing_period' => $period, 'success_url' => $successUrl, 'cancel_url' => $cancelUrl, ), static fn ( mixed $value ): bool => $value !== '' && $value !== null ) ), ) ); if ( function_exists( 'is_wp_error' ) && is_wp_error( $response ) ) { $message = method_exists( $response, 'get_error_message' ) ? (string) $response->get_error_message() : 'affilun Cloud checkout request failed.'; return $this->errorResponse( 'affilun_cloud_checkout_transport_error', 'affilun Cloud checkout request failed: ' . $message, ErrorEnvelope::STATUS_SERVICE_UNAVAILABLE, $ctx, array(), true ); } $status = is_array( $response ) && function_exists( 'wp_remote_retrieve_response_code' ) ? (int) wp_remote_retrieve_response_code( $response ) : 0; $raw = is_array( $response ) && function_exists( 'wp_remote_retrieve_body' ) ? (string) wp_remote_retrieve_body( $response ) : ''; $data = json_decode( $raw, true ); if ( $status < 200 || $status >= 300 ) { if ( is_array( $data ) ) { return $this->cloudUpstreamErrorResponse( $data, $status, $ctx ); } throw new RuntimeException( 'affilun Cloud checkout request failed.' ); } if ( ! is_array( $data ) ) { throw new RuntimeException( 'affilun Cloud checkout response was invalid.' ); } $checkout = $data['data'] ?? $data; if ( ! is_array( $checkout ) || ! isset( $checkout['url'] ) || ! is_scalar( $checkout['url'] ) ) { throw new RuntimeException( 'affilun Cloud checkout response was invalid.' ); } } catch ( Throwable $unused ) { unset( $unused ); return $this->errorResponse( ErrorEnvelope::CODE_INTERNAL_ERROR, 'affilun Cloud checkout is unavailable.', ErrorEnvelope::STATUS_SERVICE_UNAVAILABLE, $ctx, array(), true ); } return $this->successResponse( SuccessEnvelope::fromContext( array( 'id' => is_scalar( $checkout['id'] ?? null ) ? (string) $checkout['id'] : '', 'url' => (string) $checkout['url'], ), $ctx ), 200 ); } /** * `POST /affilun/v1/settings/cloud/provision-site` dispatch. * * Completes a successful Stripe checkout by asking affilun Cloud to issue the * site API key, then persists it in the local plugin settings. * * @param mixed $request WP_REST_Request, or array shim. * * @return array §4.1 envelope + synthetic status. */ public function provisionCloudSite( mixed $request ): array { $required = $this->capabilityPolicy::requiredForWrite( self::ROUTE_TOKEN_CLOUD_PROVISION_SITE ); $ctx = $this->buildContext( $request, $required ); if ( ! $this->isAuthorized( $ctx, $required ) ) { return $this->errorResponse( ErrorEnvelope::CODE_FORBIDDEN, 'Insufficient capabilities.', ErrorEnvelope::STATUS_FORBIDDEN, $ctx ); } if ( $this->settingsRepository === null ) { return $this->errorResponse( ErrorEnvelope::CODE_INTERNAL_ERROR, 'Settings repository unavailable.', ErrorEnvelope::STATUS_INTERNAL_SERVER_ERROR, $ctx ); } $body = $this->requestAdapter->jsonBody( $request ); $sessionId = self::stringFromArray( $body, 'checkout_session_id' ); if ( $sessionId === '' ) { return $this->errorResponse( ErrorEnvelope::CODE_VALIDATION_FAILED, 'Checkout session id is required.', ErrorEnvelope::STATUS_UNPROCESSABLE_ENTITY, $ctx ); } try { $current = $this->settingsRepository->load(); $endpoint = $current->cloud()->endpoint(); $response = CloudHttpOptions::safePost( $endpoint . '/billing/plugin/provision-site', array( 'timeout' => 5, 'headers' => array( 'Accept' => 'application/json', 'Content-Type' => 'application/json', ), 'body' => wp_json_encode( array( 'checkout_session_id' => $sessionId ) ), ) ); if ( function_exists( 'is_wp_error' ) && is_wp_error( $response ) ) { throw new RuntimeException( 'affilun Cloud provisioning request failed.' ); } $status = is_array( $response ) && function_exists( 'wp_remote_retrieve_response_code' ) ? (int) wp_remote_retrieve_response_code( $response ) : 0; $raw = is_array( $response ) && function_exists( 'wp_remote_retrieve_body' ) ? (string) wp_remote_retrieve_body( $response ) : ''; $data = json_decode( $raw, true ); if ( $status < 200 || $status >= 300 || ! is_array( $data ) ) { throw new RuntimeException( 'affilun Cloud provisioning request failed.' ); } $payload = $data['data'] ?? $data; if ( ! is_array( $payload ) || ! is_array( $payload['site'] ?? null ) || ! is_array( $payload['api_key'] ?? null ) ) { throw new RuntimeException( 'affilun Cloud provisioning response was invalid.' ); } $siteId = self::stringFromArray( $payload['site'], 'site_id' ); $apiKey = self::stringFromArray( $payload['api_key'], 'secret' ); $plan = self::stringFromArray( $payload, 'plan' ) ?: 'personal'; if ( $siteId === '' || $apiKey === '' ) { throw new RuntimeException( 'affilun Cloud provisioning response was incomplete.' ); } $cloud = new CloudSettings( true, $endpoint, $siteId, $apiKey, $plan ); $this->settingsRepository->save( $current->withCloud( $cloud ) ); } catch ( Throwable $unused ) { unset( $unused ); return $this->errorResponse( ErrorEnvelope::CODE_INTERNAL_ERROR, 'affilun Cloud provisioning is unavailable.', ErrorEnvelope::STATUS_SERVICE_UNAVAILABLE, $ctx, array(), true ); } return $this->successResponse( SuccessEnvelope::fromContext( array( 'cloud' => array( 'enabled' => $cloud->enabled(), 'configured' => $cloud->isConfigured(), 'endpoint' => $cloud->endpoint(), 'site_id' => $cloud->siteId(), 'api_key_masked' => $cloud->maskedApiKey(), 'plan' => $cloud->plan(), ), ), $ctx ), 200 ); } /** * `POST /affilun/v1/settings/cloud/connection-email` dispatch. * * Sends the site-connection continuation email through affilun Cloud. * * @param mixed $request WP_REST_Request, or array shim. * * @return array §4.1 envelope + synthetic status. */ public function sendCloudConnectionEmail( mixed $request ): array { $required = $this->capabilityPolicy::requiredForWrite( self::ROUTE_TOKEN_CLOUD_CONNECTION_EMAIL ); $ctx = $this->buildContext( $request, $required ); if ( ! $this->isAuthorized( $ctx, $required ) ) { return $this->errorResponse( ErrorEnvelope::CODE_FORBIDDEN, 'Insufficient capabilities.', ErrorEnvelope::STATUS_FORBIDDEN, $ctx ); } if ( $this->settingsRepository === null ) { return $this->errorResponse( ErrorEnvelope::CODE_INTERNAL_ERROR, 'Settings repository unavailable.', ErrorEnvelope::STATUS_INTERNAL_SERVER_ERROR, $ctx ); } $body = $this->requestAdapter->jsonBody( $request ); $email = self::stringFromArray( $body, 'email' ); $siteUrl = self::stringFromArray( $body, 'site_url' ); $siteName = self::stringFromArray( $body, 'site_name' ); $returnUrl = self::stringFromArray( $body, 'return_url' ); $state = self::stringFromArray( $body, 'state' ); if ( $email === '' || $siteUrl === '' || $siteName === '' || $returnUrl === '' || $state === '' ) { return $this->errorResponse( ErrorEnvelope::CODE_VALIDATION_FAILED, 'Email, site URL, site name, return URL, and state are required.', ErrorEnvelope::STATUS_UNPROCESSABLE_ENTITY, $ctx ); } try { $endpoint = $this->settingsRepository->load()->cloud()->endpoint(); $response = CloudHttpOptions::safePost( $endpoint . '/billing/plugin/site-connection/email', array( 'timeout' => 15, 'headers' => array( 'Accept' => 'application/json', 'Content-Type' => 'application/json', ), 'body' => wp_json_encode( array( 'email' => $email, 'site_url' => $siteUrl, 'site_name' => $siteName, 'return_url' => $returnUrl, 'state' => $state, ) ), ) ); if ( function_exists( 'is_wp_error' ) && is_wp_error( $response ) ) { throw new RuntimeException( 'affilun Cloud connection email request failed.' ); } $status = is_array( $response ) && function_exists( 'wp_remote_retrieve_response_code' ) ? (int) wp_remote_retrieve_response_code( $response ) : 0; $raw = is_array( $response ) && function_exists( 'wp_remote_retrieve_body' ) ? (string) wp_remote_retrieve_body( $response ) : ''; $data = json_decode( $raw, true ); if ( $status < 200 || $status >= 300 || ! is_array( $data ) ) { if ( is_array( $data ) ) { return $this->cloudUpstreamErrorResponse( $data, $status, $ctx ); } throw new RuntimeException( 'affilun Cloud connection email request failed.' ); } $payload = $data['data'] ?? $data; if ( ! is_array( $payload ) ) { throw new RuntimeException( 'affilun Cloud connection email response was invalid.' ); } } catch ( Throwable $unused ) { unset( $unused ); return $this->errorResponse( ErrorEnvelope::CODE_INTERNAL_ERROR, 'affilun Cloud connection email is unavailable.', ErrorEnvelope::STATUS_SERVICE_UNAVAILABLE, $ctx, array(), true ); } return $this->successResponse( SuccessEnvelope::fromContext( array( 'accepted' => true, 'expires_at' => is_scalar( $payload['expires_at'] ?? null ) ? (string) $payload['expires_at'] : null, 'email_delivery' => is_array( $payload['email_delivery'] ?? null ) ? $payload['email_delivery'] : array(), ), $ctx ), 200 ); } /** * `POST /affilun/v1/settings/cloud/status` dispatch. * * Checks whether affilun Cloud already knows this site before the plugin * offers a new checkout flow. This does not mark Cloud as configured because * the plugin still needs a site API key from the connection flow. * * @param mixed $request WP_REST_Request, or array shim. * * @return array §4.1 envelope + synthetic status. */ public function readCloudStatus( mixed $request ): array { $required = $this->capabilityPolicy::requiredForWrite( self::ROUTE_TOKEN_CLOUD_STATUS ); $ctx = $this->buildContext( $request, $required ); if ( ! $this->isAuthorized( $ctx, $required ) ) { return $this->errorResponse( ErrorEnvelope::CODE_FORBIDDEN, 'Insufficient capabilities.', ErrorEnvelope::STATUS_FORBIDDEN, $ctx ); } if ( $this->settingsRepository === null ) { return $this->errorResponse( ErrorEnvelope::CODE_INTERNAL_ERROR, 'Settings repository unavailable.', ErrorEnvelope::STATUS_INTERNAL_SERVER_ERROR, $ctx ); } $body = $this->requestAdapter->jsonBody( $request ); $siteUrl = self::stringFromArray( $body, 'site_url' ); if ( $siteUrl === '' ) { return $this->errorResponse( ErrorEnvelope::CODE_VALIDATION_FAILED, 'Site URL is required.', ErrorEnvelope::STATUS_UNPROCESSABLE_ENTITY, $ctx ); } try { $endpoint = $this->settingsRepository->load()->cloud()->endpoint(); $response = CloudHttpOptions::safePost( $endpoint . '/billing/plugin/site-status', array( 'timeout' => 5, 'headers' => array( 'Accept' => 'application/json', 'Content-Type' => 'application/json', ), 'body' => wp_json_encode( array( 'site_url' => $siteUrl ) ), ) ); if ( function_exists( 'is_wp_error' ) && is_wp_error( $response ) ) { throw new RuntimeException( 'affilun Cloud site status request failed.' ); } $status = is_array( $response ) && function_exists( 'wp_remote_retrieve_response_code' ) ? (int) wp_remote_retrieve_response_code( $response ) : 0; $raw = is_array( $response ) && function_exists( 'wp_remote_retrieve_body' ) ? (string) wp_remote_retrieve_body( $response ) : ''; $data = json_decode( $raw, true ); if ( $status < 200 || $status >= 300 ) { if ( is_array( $data ) ) { return $this->cloudUpstreamErrorResponse( $data, $status, $ctx ); } throw new RuntimeException( 'affilun Cloud site status request failed.' ); } if ( ! is_array( $data ) ) { throw new RuntimeException( 'affilun Cloud site status response was invalid.' ); } $payload = $data['data'] ?? $data; if ( ! is_array( $payload ) ) { throw new RuntimeException( 'affilun Cloud site status response was invalid.' ); } } catch ( Throwable $unused ) { unset( $unused ); return $this->errorResponse( ErrorEnvelope::CODE_INTERNAL_ERROR, 'affilun Cloud site status is unavailable.', ErrorEnvelope::STATUS_SERVICE_UNAVAILABLE, $ctx, array(), true ); } return $this->successResponse( SuccessEnvelope::fromContext( array( 'site' => is_array( $payload['site'] ?? null ) ? $payload['site'] : array(), 'plan' => is_scalar( $payload['plan'] ?? null ) ? (string) $payload['plan'] : 'free', 'cloud_access' => (bool) ( $payload['cloud_access'] ?? false ), 'connection_required' => (bool) ( $payload['connection_required'] ?? false ), ), $ctx ), 200 ); } /** * `POST /affilun/v1/settings/cloud/connection-token` dispatch. * * Exchanges an affilun Cloud account-page connection token for a one-time * site API key, then persists it in local plugin settings. * * @param mixed $request WP_REST_Request, or array shim. * * @return array §4.1 envelope + synthetic status. */ public function exchangeCloudConnectionToken( mixed $request ): array { $required = $this->capabilityPolicy::requiredForWrite( self::ROUTE_TOKEN_CLOUD_CONNECTION_TOKEN ); $ctx = $this->buildContext( $request, $required ); if ( ! $this->isAuthorized( $ctx, $required ) ) { return $this->errorResponse( ErrorEnvelope::CODE_FORBIDDEN, 'Insufficient capabilities.', ErrorEnvelope::STATUS_FORBIDDEN, $ctx ); } if ( $this->settingsRepository === null ) { return $this->errorResponse( ErrorEnvelope::CODE_INTERNAL_ERROR, 'Settings repository unavailable.', ErrorEnvelope::STATUS_INTERNAL_SERVER_ERROR, $ctx ); } $body = $this->requestAdapter->jsonBody( $request ); $connectionToken = self::stringFromArray( $body, 'connection_token' ); $state = self::stringFromArray( $body, 'state' ); if ( $connectionToken === '' || $state === '' ) { return $this->errorResponse( ErrorEnvelope::CODE_VALIDATION_FAILED, 'Connection token and state are required.', ErrorEnvelope::STATUS_UNPROCESSABLE_ENTITY, $ctx ); } try { $current = $this->settingsRepository->load(); $endpoint = $current->cloud()->endpoint(); $response = CloudHttpOptions::safePost( $endpoint . '/billing/plugin/site-connection/exchange', array( 'timeout' => 5, 'headers' => array( 'Accept' => 'application/json', 'Content-Type' => 'application/json', ), 'body' => wp_json_encode( array( 'connection_token' => $connectionToken, 'state' => $state, ) ), ) ); if ( function_exists( 'is_wp_error' ) && is_wp_error( $response ) ) { throw new RuntimeException( 'affilun Cloud connection token exchange failed.' ); } $status = is_array( $response ) && function_exists( 'wp_remote_retrieve_response_code' ) ? (int) wp_remote_retrieve_response_code( $response ) : 0; $raw = is_array( $response ) && function_exists( 'wp_remote_retrieve_body' ) ? (string) wp_remote_retrieve_body( $response ) : ''; $data = json_decode( $raw, true ); if ( $status < 200 || $status >= 300 || ! is_array( $data ) ) { if ( is_array( $data ) ) { return $this->cloudUpstreamErrorResponse( $data, $status, $ctx ); } throw new RuntimeException( 'affilun Cloud connection token exchange failed.' ); } $payload = $data['data'] ?? $data; if ( ! is_array( $payload ) || ! is_array( $payload['site'] ?? null ) || ! is_array( $payload['api_key'] ?? null ) ) { throw new RuntimeException( 'affilun Cloud connection token response was invalid.' ); } $siteId = self::stringFromArray( $payload['site'], 'site_id' ); $apiKey = self::stringFromArray( $payload['api_key'], 'secret' ); $plan = self::stringFromArray( $payload, 'plan' ) ?: 'personal'; if ( $siteId === '' || $apiKey === '' ) { throw new RuntimeException( 'affilun Cloud connection token response was incomplete.' ); } $cloud = new CloudSettings( true, $endpoint, $siteId, $apiKey, $plan ); $this->settingsRepository->save( $current->withCloud( $cloud ) ); } catch ( Throwable $unused ) { unset( $unused ); return $this->errorResponse( ErrorEnvelope::CODE_INTERNAL_ERROR, 'affilun Cloud connection token exchange is unavailable.', ErrorEnvelope::STATUS_SERVICE_UNAVAILABLE, $ctx, array(), true ); } return $this->successResponse( SuccessEnvelope::fromContext( array( 'cloud' => array( 'enabled' => $cloud->enabled(), 'configured' => $cloud->isConfigured(), 'endpoint' => $cloud->endpoint(), 'site_id' => $cloud->siteId(), 'api_key_masked' => $cloud->maskedApiKey(), 'plan' => $cloud->plan(), ), ), $ctx ), 200 ); } /** * `POST /affilun/v1/settings/ai/extract-keywords` dispatch. * * Uses affilun Cloud managed AI to extract likely product-search * keywords from the current editor title and body. * * @param mixed $request WP_REST_Request, or array shim. * * @return array §4.1 envelope + synthetic status. */ public function extractAiKeywords( mixed $request ): array { $required = $this->capabilityPolicy::requiredForWrite( self::ROUTE_TOKEN_AI_EXTRACT_KEYWORDS ); $ctx = $this->buildContext( $request, $required ); if ( ! $this->isAuthorized( $ctx, $required ) ) { return $this->errorResponse( ErrorEnvelope::CODE_FORBIDDEN, 'Insufficient capabilities.', ErrorEnvelope::STATUS_FORBIDDEN, $ctx ); } if ( $this->settingsRepository === null ) { return $this->errorResponse( ErrorEnvelope::CODE_INTERNAL_ERROR, 'Settings repository unavailable.', ErrorEnvelope::STATUS_INTERNAL_SERVER_ERROR, $ctx ); } try { $cloud = $this->settingsRepository->load()->cloud(); } catch ( Throwable $unused ) { unset( $unused ); return $this->errorResponse( ErrorEnvelope::CODE_INTERNAL_ERROR, 'Settings load failed.', ErrorEnvelope::STATUS_INTERNAL_SERVER_ERROR, $ctx ); } $body = $this->requestAdapter->jsonBody( $request ); $title = $this->readBodyString( $body, 'title' ) ?? ''; $content = $this->readBodyString( $body, 'content' ) ?? ''; if ( $title === '' && $content === '' ) { return $this->errorResponse( ErrorEnvelope::CODE_VALIDATION_FAILED, 'Title or content is required.', ErrorEnvelope::STATUS_UNPROCESSABLE_ENTITY, $ctx, array( 'fields' => array( 'content' => array( 'Title or content is required.' ) ) ) ); } $extraction = $this->runCloudKeywordExtraction( $cloud, $title, $content ); if ( $extraction['ok'] !== true ) { return $this->errorResponse( $extraction['code'], $extraction['message'], $extraction['status'], $ctx, $extraction['details'], $extraction['retryable'] ); } $data = array( 'search_keywords' => $extraction['keywords'], 'candidates' => $extraction['candidates'], 'microcopy_suggestions' => $extraction['microcopy_suggestions'], 'button_label_suggestions' => $extraction['button_label_suggestions'], 'source' => 'cloud', ); return $this->successResponse( SuccessEnvelope::fromContext( $data, $ctx ), 200 ); } /** * Read a non-empty string from a JSON body map. * * @param array $body Body map. * @param string $key Field key. */ private function readBodyString( array $body, string $key ): ?string { if ( ! array_key_exists( $key, $body ) || ! is_string( $body[ $key ] ) ) { return null; } $trimmed = trim( $body[ $key ] ); return $trimmed === '' ? null : $trimmed; } /** * Execute affilun Cloud managed article suggestion request. * * @param \Affilun\Application\Settings\CloudSettings $cloud Cloud settings. * @param string $title Current post title. * @param string $content Current post body. * * @phpstan-return array{ok:true,keywords:string,candidates:list,microcopy_suggestions:list,button_label_suggestions:array,source:string}|array{ok:false,code:string,message:string,status:int,details:array,retryable:bool} */ private function runCloudKeywordExtraction( $cloud, string $title, string $content ): array { try { $result = ( new CloudArticleSuggestionProvider( $cloud, transport: $this->cloudAiTransport ) ) ->suggest( $title, $content ); return array( 'ok' => true, 'keywords' => $result->searchKeywords(), 'candidates' => $result->candidates(), 'microcopy_suggestions' => $result->microcopySuggestions(), 'button_label_suggestions' => $result->buttonLabelSuggestions(), 'source' => $result->source(), ); } catch ( AiSuggestionException $exception ) { return array( 'ok' => false, 'code' => $exception->errorCode(), 'message' => $exception->getMessage(), 'status' => $exception->status(), 'details' => $exception->details(), 'retryable' => $exception->retryable(), ); } } /** * Plan catalog for the plugin-side onboarding wizard. * * @return array */ private function cloudPlanCatalog(): array { if ( $this->settingsRepository !== null && function_exists( 'wp_remote_get' ) ) { try { $endpoint = $this->settingsRepository->load()->cloud()->endpoint(); $response = CloudHttpOptions::safeGet( $endpoint . '/billing/plans', array( 'timeout' => 5, 'headers' => array( 'Accept' => 'application/json' ), ) ); if ( ! ( function_exists( 'is_wp_error' ) && is_wp_error( $response ) ) ) { $status = is_array( $response ) && function_exists( 'wp_remote_retrieve_response_code' ) ? (int) wp_remote_retrieve_response_code( $response ) : 0; $raw = is_array( $response ) && function_exists( 'wp_remote_retrieve_body' ) ? (string) wp_remote_retrieve_body( $response ) : ''; $data = json_decode( $raw, true ); if ( $status >= 200 && $status < 300 && is_array( $data ) ) { $catalog = $data['data'] ?? $data; if ( is_array( $catalog ) && isset( $catalog['plans'] ) && is_array( $catalog['plans'] ) ) { return self::normalizeCloudPlanCatalog( $catalog ); } } } } catch ( Throwable $unused ) { unset( $unused ); } } return self::fallbackCloudPlanCatalog(); } /** * Fallback alpha plan catalog for the plugin-side onboarding wizard. * * @return array */ private static function fallbackCloudPlanCatalog(): array { return array( 'currency' => 'JPY', 'default_plan' => 'personal', 'default_billing_period' => 'monthly', 'price_disclaimer' => '金額は Stripe Checkout で最終確認してください。', 'free_plan' => array( 'code' => 'free', 'name' => 'Free', 'available_without_checkout' => true, 'quotas' => array( 'ai.extract_keywords' => 0, 'ai.cost_jpy_micros' => 0, 'ai.article_draft' => 0, 'link_health.url' => 0, ), 'message' => 'Free は接続維持用のプランです。Cloud AI、AIスキャン、記事下書き、リンクヘルス、キャンペーン、売れ筋候補は有料またはトライアル中のプランで利用できます。', ), 'trial_policy' => array( 'supported_status' => 'trialing', 'message' => 'Stripe の trialing サブスクリプションでは、トライアル終了まで選択した有料プランの機能と利用枠が適用されます。', ), 'plans' => array( array( 'code' => 'personal', 'name' => 'Personal', 'prices' => array( 'monthly' => array( 'display' => 'Stripe Checkout で確定' ), 'yearly' => array( 'display' => 'Stripe Checkout で確定' ), ), 'features' => array( '1サイトまで', 'AIキーワード抽出', 'AIスキャン機能', 'AI記事下書き生成', 'キャンペーン提案', '売れ筋アフィリエイト候補', 'リンクヘルスチェック', '利用状況の確認', ), 'disclaimer' => 'Plan limits and billing details are confirmed during Stripe Checkout.', ), array( 'code' => 'growth', 'name' => 'Growth', 'prices' => array( 'monthly' => array( 'display' => 'Stripe Checkout で確定' ), 'yearly' => array( 'display' => 'Stripe Checkout で確定' ), ), 'features' => array( '5サイトまで', 'Personal の全機能', 'スケジュール型リンクヘルス', ), 'disclaimer' => 'Plan limits and billing details are confirmed during Stripe Checkout.', ), array( 'code' => 'agency', 'name' => 'Agency', 'prices' => array( 'monthly' => array( 'display' => 'Stripe Checkout で確定' ), 'yearly' => array( 'display' => 'Stripe Checkout で確定' ), ), 'features' => array( 'サイト数制限なし', 'Growth の全機能', 'より高い利用枠', '複数サイト管理', ), 'disclaimer' => 'Agency limits may vary by agreement and are confirmed during checkout or onboarding.', ), ), ); } /** * Normalize AFFILUN API plan catalog for the onboarding wizard. * * @param array $catalog Raw catalog. * * @return array */ private static function normalizeCloudPlanCatalog( array $catalog ): array { $catalog['currency'] = is_scalar( $catalog['currency'] ?? null ) ? (string) $catalog['currency'] : 'JPY'; $catalog['default_plan'] = is_scalar( $catalog['default_plan'] ?? null ) ? (string) $catalog['default_plan'] : 'personal'; $catalog['default_billing_period'] = is_scalar( $catalog['default_billing_period'] ?? null ) ? (string) $catalog['default_billing_period'] : 'monthly'; $catalog['price_disclaimer'] = is_scalar( $catalog['price_disclaimer'] ?? null ) ? (string) $catalog['price_disclaimer'] : '金額は Stripe Checkout で最終確認してください。'; $catalog['free_plan'] = self::normalizeCloudPlanMessageObject( $catalog['free_plan'] ?? null ); $catalog['trial_policy'] = self::normalizeCloudPlanMessageObject( $catalog['trial_policy'] ?? null ); $plans = array(); foreach ( $catalog['plans'] ?? array() as $plan ) { if ( ! is_array( $plan ) ) { continue; } $plan['features'] = self::humanizeCloudFeatures( $plan ); $plans[] = $plan; } $catalog['plans'] = $plans; return $catalog; } /** * Normalize free/trial plan message objects. * * @param mixed $value Raw object. * * @return array */ private static function normalizeCloudPlanMessageObject( mixed $value ): array { return is_array( $value ) ? $value : array(); } /** * Convert machine feature flags to user-facing Japanese labels when needed. * * @param array $plan Plan row. * * @return list */ private static function humanizeCloudFeatures( array $plan ): array { $raw = $plan['feature_summaries'] ?? $plan['features'] ?? array(); if ( ! is_array( $raw ) ) { return array(); } $labels = array( 'ai.extract_keywords' => 'AIキーワード抽出', 'ai.affiliate_scan' => 'AIスキャン機能', 'ai.article_draft' => 'AI記事下書き生成', 'link_health.check' => 'リンクヘルスチェック', 'campaigns.read' => 'キャンペーン提案', 'top_affiliate_candidates.read' => '売れ筋アフィリエイト候補', 'link_health.scheduled' => 'スケジュール型リンクヘルス', 'billing.portal' => '請求ポータル', ); $result = array(); foreach ( $raw as $item ) { if ( ! is_scalar( $item ) ) { continue; } $value = (string) $item; $result[] = $labels[ $value ] ?? $value; } return $result; } /** * Read a trimmed scalar string from an array. * * @param array $source Source array. * @param string $key Key to read. */ private static function stringFromArray( array $source, string $key ): string { $value = $source[ $key ] ?? ''; return is_scalar( $value ) ? trim( (string) $value ) : ''; } /** * 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 ); } /** * Translate an affilun Cloud upstream error into the plugin REST envelope. * * @param array $data Upstream JSON body. * @param int $status Upstream HTTP status. * @param RequestContext $ctx Per-request meta. * * @return array */ private function cloudUpstreamErrorResponse( array $data, int $status, RequestContext $ctx ): array { $error = isset( $data['error'] ) && is_array( $data['error'] ) ? $data['error'] : array(); $code = self::stringFromArray( $error, 'code' ); if ( $code === '' ) { $code = ErrorEnvelope::CODE_INTERNAL_ERROR; } $message = self::stringFromArray( $error, 'message' ); if ( $message === '' ) { $message = 'affilun Cloud checkout is unavailable.'; } $details = isset( $error['details'] ) && is_array( $error['details'] ) ? $error['details'] : array(); return $this->errorResponse( $code, $message, $status, $ctx, $details, $status >= 500 ); } /** * 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 ); } /** * 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}. * * Per §13.1's "no internal detail leakage" posture the * unknown-exception path is mapped to a §4.3 500 * `affilun_internal_error` envelope with the literal message * `'Internal server error.'`. The original exception's * class and message are NOT forwarded. * * The unused {@see ProviderExceptionMapper} dependency is * held in the constructor for symmetry with the other * Phase-6 / Phase-7 controllers; future slices that grow * the use case to resolve provider health will route the * §15.2 provider exceptions through the mapper here. * * @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() ); } 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 ); } }