/...` and wraps every callback with: * - A final capability check (defaults to `manage_options`). * - A tier gate that returns 404 if the module is unavailable on this * install (Pro module without active Pro plugin → 404, not 403, so * the route looks like it doesn't exist). * - A conflict gate (refuse strategy → 409 with conflict details). * * Modules declare routes via Module::rest_routes(). They don't repeat the * cap check / tier gate / conflict gate — Rest_Manager always enforces. * * The pre-existing /xspeed/v1/status, /settings, /cache/purge, * /cache/toggle, /onboarding/* routes (registered by Rest_Api and * Onboarding) continue to work alongside per-module routes. They'll move * onto the module pattern when those features are refactored. * * @package XSpeed */ namespace XSpeed; defined( 'ABSPATH' ) || exit; final class Rest_Manager { public const NAMESPACE_V1 = 'xspeed/v1'; /** * Register every route a module declared, prefixed with its slug. */ public static function register_module( Module $module ): void { // rest_routes() is resolved INSIDE the callback, not here. // // This method runs at plugins_loaded, before `init`. Calling // rest_routes() there makes a module build its settings schema, and // those schemas carry __() labels — so WordPress emitted a // _load_textdomain_just_in_time notice for every module, on every // request including the front end (135 per page load with WP_DEBUG on). // The eager call existed only to skip add_action() for modules with no // routes; deferring costs one no-op hook each and moves all translation // work to where it belongs. (QA, 9 Aug 2026) add_action( 'rest_api_init', static function () use ( $module ) { $routes = $module->rest_routes(); if ( empty( $routes ) ) { return; } $slug = $module->slug(); foreach ( $routes as $route ) { $path = '/' . trim( $slug, '/' ) . '/' . ltrim( $route['path'] ?? '', '/' ); $path = rtrim( $path, '/' ); $args = array( 'methods' => $route['methods'] ?? 'GET', 'callback' => self::wrap_callback( $module, $route ), 'permission_callback' => self::wrap_permission( $module, $route ), ); if ( isset( $route['args'] ) ) { $args['args'] = $route['args']; } register_rest_route( self::NAMESPACE_V1, $path, $args ); } } ); } /** * Wrap a module's callback with the tier + conflict gates. */ private static function wrap_callback( Module $module, array $route ): callable { $callback = $route['callback'] ?? null; $feature = $route['feature'] ?? null; // optional sub-feature key for conflict resolution. return static function ( \WP_REST_Request $request ) use ( $module, $callback, $feature ) { // Tier gate — Pro route without active Pro plugin looks like it doesn't exist. if ( ! Tier_Registry::is_available( $module ) ) { return new \WP_Error( 'rest_no_route', __( 'No route was found matching the URL and request method.', 'xspeed' ), array( 'status' => 404 ) ); } // Conflict gate — refuse-strategy → 409. if ( $feature ) { $reason = Conflict_Registry::why_blocked( $module->slug(), $feature ); if ( $reason ) { return new \WP_Error( 'xspeed_conflict_refused', $reason, array( 'status' => 409 ) ); } } if ( ! is_callable( $callback ) ) { return new \WP_Error( 'xspeed_no_callback', 'Module REST callback is not callable.', array( 'status' => 500 ) ); } return self::run_isolated( $callback, $request, $module->slug() ); }; } /** * Backstop for every module REST callback. Containment, not a substitute * for per-query guards: callbacks should still avoid failing queries. * * Two things leak non-JSON into a REST body and break the client's * JSON.parse ("Unexpected token '<'"): * 1. $wpdb echoes "WordPress database error: …" as an HTML