PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.19
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.19
1.10.19 1.10.18 1.10.17 1.10.16 1.10.15 1.10.13 1.10.14 1.10.12 1.10.11 1.10.10 1.10.9 1.10.8 untagged-3d9b7ccddc54df87c672 1.10.7 1.10.6 1.10.5 1.10.3 1.10.4 1.10.2 1.10.1 1.10.0 1.9.17 1.9.15 1.9.16 1.9.14 All 163 releases
← All changes | includes/API.php +228 -220 1.9.161.10.19 View file →
@@ -1,7 +1,7 @@
1 1 <?php
2 2 /**
3 - * WCPOS REST API Class, ie: /wcpos/v1/ endpoints.
3 + * WCPOS REST API class.
4 4 *
5 5 * @author Paul Kilmurray <paul@kilbot.com>
6 6 *
7 7 * @see http://wcpos.com
@@ -9,10 +9,12 @@
9 9 */
10 10
11 11 namespace WCPOS\WooCommercePOS;
12 12
13 -use Ramsey\Uuid\Uuid;
13 +use WCPOS\WooCommercePOS\API\Controller_Registry;
14 14 use WCPOS\WooCommercePOS\Services\Auth;
15 +use WCPOS\WooCommercePOS\Services\Client_Signal;
16 +use WCPOS\WooCommercePOS\Services\Settings as SettingsService;
15 17 use WP_HTTP_Response;
16 18 use WP_REST_Request;
17 19 use WP_REST_Response;
18 20 use WP_REST_Server;
@@ -21,21 +23,25 @@
21 23 * API class.
22 24 */
23 25 class API {
24 26 /**
25 - * WCPOS REST API namespaces and endpoints.
27 + * WCPOS REST API namespaces.
28 + */
29 + public const ROUTE_NAMESPACES = array( 'wcpos/v1', 'wcpos/v2' );
30 +
31 + /**
32 + * Controller instances and route attribution.
26 33 *
27 - * @var array
34 + * @var Controller_Registry
28 35 */
29 - protected $controllers = array();
36 + protected Controller_Registry $registry;
30 37
31 38 /**
32 - * Map of route patterns to controller keys.
33 - * Built during register_routes() for use in rest_dispatch_request().
39 + * Route permission-gate classifier.
34 40 *
35 - * @var array<string, string>
41 + * @var API\Route_Classifier
36 42 */
37 - protected $route_map = array();
43 + protected $route_classifier;
38 44
39 45 /**
40 46 * Flag to check if authentication has been checked.
41 47 *
@@ -43,8 +49,15 @@
43 49 */
44 50 protected $is_auth_checked = false;
45 51
46 52 /**
53 + * Validation error for a token presented during this request.
54 + *
55 + * @var \WP_Error|null
56 + */
57 + private $auth_error = null;
58 +
59 + /**
47 60 * Flag to track whether WCPOS successfully authenticated the current request
48 61 * via its own Bearer token. Used to suppress errors from third-party JWT
49 62 * plugins that inspected the same Authorization header but could not validate
50 63 * a WCPOS-issued token with their own secret.
@@ -58,12 +71,8 @@
58 71 */
59 72 public function __construct() {
60 73 $this->register_routes();
61 74
62 - // Allows requests from WCPOS Desktop and Mobile Apps.
63 - add_filter( 'rest_allowed_cors_headers', array( $this, 'rest_allowed_cors_headers' ), 10, 1 );
64 - add_filter( 'rest_pre_serve_request', array( $this, 'rest_pre_serve_request' ), 10, 4 );
65 -
66 75 /*
67 76 * Adds authentication to for JWT bearer tokens
68 77 * - We run determine_current_user at 20 to allow other plugins to run first
69 78 */
@@ -75,146 +84,48 @@
75 84
76 85 // These filters allow changes to the WC REST API response.
77 86 add_filter( 'rest_dispatch_request', array( $this, 'rest_dispatch_request' ), 10, 4 );
78 87 add_filter( 'rest_pre_dispatch', array( $this, 'rest_pre_dispatch' ), 10, 3 );
88 + add_filter( 'rest_pre_dispatch', array( $this, 'clear_third_party_jwt_error' ), 50, 3 );
89 + add_filter( 'rest_post_dispatch', array( $this, 'rest_post_dispatch' ), 10, 3 );
79 90 }
80 91
81 92 /**
82 - * Register routes for all controllers.
93 + * Get the WCPOS REST API namespaces.
94 + *
95 + * @return string[] REST API namespaces.
83 96 */
84 - public function register_routes(): void {
97 + public function get_route_namespaces(): array {
85 98 /**
86 - * Filter the list of controller classes used in the WCPOS REST API.
99 + * Filter the list of namespaces used in the WCPOS REST API.
87 100 *
88 - * This filter allows customizing or extending the set of controller classes that handle
89 - * REST API routes for the WCPOS. By filtering these controllers, plugins can
90 - * modify existing endpoints or add new controllers for additional functionality.
101 + * This filter is strictly additive: plugins can register additional WCPOS REST
102 + * API namespaces, but the core namespaces cannot be removed — the central
103 + * permission gate must keep covering every registered core route. Controllers
104 + * remain responsible for declaring any special route classifications within
105 + * added namespaces.
91 106 *
92 - * @since 1.5.0
107 + * @since 1.10.0
93 108 *
94 - * @param array $controllers Associative array of controller identifiers to their corresponding class names.
95 - * - 'auth' => Fully qualified name of the class handling authentication.
96 - * - 'settings' => Fully qualified name of the class handling settings.
97 - * - 'cashier' => Fully qualified name of the class handling cashier management.
98 - * - 'products' => Fully qualified name of the class handling products.
99 - * - 'product_variations' => Fully qualified name of the class handling product variations.
100 - * - 'orders' => Fully qualified name of the class handling orders.
101 - * - 'customers' => Fully qualified name of the class handling customers.
102 - * - 'product_tags' => Fully qualified name of the class handling product tags.
103 - * - 'product_categories' => Fully qualified name of the class handling product categories.
104 - * - 'taxes' => Fully qualified name of the class handling taxes.
105 - * - 'shipping_methods' => Fully qualified name of the class handling shipping methods.
106 - * - 'tax_classes' => Fully qualified name of the class handling tax classes.
107 - * - 'order_statuses' => Fully qualified name of the class handling order statuses.
109 + * @param string[] $namespaces REST API namespaces.
108 110 */
109 - $classes = apply_filters(
110 - 'woocommerce_pos_rest_api_controllers',
111 - array(
112 - // WCPOS rest api controllers.
113 - 'auth' => API\Auth::class,
114 - 'settings' => API\Settings::class,
115 - 'cashier' => API\Cashier::class,
116 - 'templates' => API\Templates_Controller::class,
117 - 'receipts' => API\Receipts_Controller::class,
118 - 'print_jobs' => API\Print_Jobs_Controller::class,
111 + $namespaces = apply_filters( 'woocommerce_pos_rest_namespaces', self::ROUTE_NAMESPACES );
119 112
120 - // TODO: remove this?
121 - 'stores' => API\Stores::class,
122 - 'extensions' => API\Extensions::class,
123 - 'logs' => API\Logs::class,
124 - 'payment_gateways' => API\Payment_Gateways::class,
125 - 'gateway_bootstrap' => API\Gateway_Bootstrap_Controller::class,
126 - 'checkout' => API\Checkout_Controller::class,
127 -
128 - // extend WC REST API controllers.
129 - 'products' => API\Products_Controller::class,
130 - 'product_variations' => API\Product_Variations_Controller::class,
131 - 'orders' => API\Orders_Controller::class,
132 - 'customers' => API\Customers_Controller::class,
133 - 'product_tags' => API\Product_Tags_Controller::class,
134 - 'product_categories' => API\Product_Categories_Controller::class,
135 - 'product_brands' => API\Product_Brands_Controller::class,
136 - 'coupons' => API\Coupons_Controller::class,
137 - 'taxes' => API\Taxes_Controller::class,
138 - 'shipping_methods' => API\Shipping_Methods_Controller::class,
139 - 'tax_classes' => API\Tax_Classes_Controller::class,
140 - 'order_statuses' => API\Data_Order_Statuses_Controller::class,
141 - )
142 - );
143 -
144 - foreach ( $classes as $key => $class ) {
145 - if ( class_exists( $class ) ) {
146 - $this->controllers[ $key ] = new $class();
147 - $this->controllers[ $key ]->register_routes();
148 - }
149 - }
150 -
151 - // Build route map for use in rest_dispatch_request().
152 - $rest_server = rest_get_server();
153 - $all_routes = $rest_server->get_routes( 'wcpos/v1' );
154 -
155 - foreach ( $all_routes as $route_pattern => $route_handlers ) {
156 - foreach ( $route_handlers as $route_handler ) {
157 - $callback = $route_handler['callback'] ?? null;
158 -
159 - // Extract the controller object from the callback.
160 - $controller_obj = null;
161 - if ( \is_array( $callback ) && isset( $callback[0] ) && \is_object( $callback[0] ) ) {
162 - $controller_obj = $callback[0];
163 - } elseif ( $callback instanceof \Closure ) {
164 - // WC 10.5+ RestApiCache wraps callbacks in closures.
165 - // Use reflection to extract the bound $this.
166 - $ref = new \ReflectionFunction( $callback );
167 - $controller_obj = $ref->getClosureThis();
168 - }
169 -
170 - if ( ! $controller_obj ) {
171 - continue;
172 - }
173 -
174 - // Find which controller key this object belongs to.
175 - foreach ( $this->controllers as $key => $registered_controller ) {
176 - if ( $controller_obj === $registered_controller ) {
177 - $this->route_map[ $route_pattern ] = $key;
178 - break;
179 - }
180 - }
181 - }
182 - }
113 + return array_values( array_unique( array_merge( self::ROUTE_NAMESPACES, (array) $namespaces ) ) );
183 114 }
184 115
185 116 /**
186 - * Add CORS headers to the REST API response.
187 - *
188 - * @param string[] $allow_headers The list of request headers to allow.
189 - *
190 - * @return string[] $allow_headers
117 + * Register routes for all controllers.
191 118 */
192 - public function rest_allowed_cors_headers( array $allow_headers ): array {
193 - $allow_headers[] = 'X-WCPOS';
194 - $allow_headers[] = 'X-HTTP-Method-Override';
195 - $allow_headers[] = 'X-WCPOS-Idempotency-Key';
119 + public function register_routes(): void {
120 + $route_namespaces = $this->get_route_namespaces();
121 + $this->route_classifier = new API\Route_Classifier( $route_namespaces );
196 122
197 - return $allow_headers;
198 - }
123 + $this->registry = new Controller_Registry();
124 + $this->registry->register( $this->route_classifier );
199 125
200 - /**
201 - * Add Access Control Allow Headers for POS app.
202 - *
203 - * NOTE: I have seen this filter called with NULL for $served, it should be a boolean.
204 - *
205 - * @param mixed $served Whether the request has already been served.
206 - * Default false.
207 - * @param WP_HTTP_Response $result Result to send to the client. Usually a `WP_REST_Response`.
208 - * @param WP_REST_Request $request Request used to generate the response.
209 - * @param WP_REST_Server $server Server instance.
210 - *
211 - * @return bool $served
212 - */
213 - public function rest_pre_serve_request( $served, WP_HTTP_Response $result, WP_REST_Request $request, WP_REST_Server $server ) {
214 - $server->send_header( 'Access-Control-Allow-Origin', '*' );
215 -
216 - return $served;
126 + // Sync classifications are independent of feature-gated route registration.
127 + $this->route_classifier->merge( Sync\Api::route_classifications() );
217 128 }
218 129
219 130 /**
220 131 * Check request for any login tokens.
@@ -265,13 +176,14 @@
265 176 * 1. WooCommerce issue #26847: determine_current_user may not be called when
266 177 * WordPress has already cached the current user. We attempt auth here as a
267 178 * fallback.
268 179 *
269 - * 2. JWT plugin conflict: a third-party JWT plugin (e.g. jwt-authentication-for-wp-rest-api)
270 - * sees our Bearer token, fails to validate it with its own secret, and returns
271 - * a WP_Error via rest_authentication_errors at priority 10. We run at priority 50
272 - * and attempt our own Bearer-token validation. If it succeeds, we clear the
273 - * stale error — our authentication wins.
180 + * 2. JWT plugin conflict: a third-party JWT plugin sees our Bearer token, fails
181 + * to validate it with its own secret, and returns a WP_Error via
182 + * rest_authentication_errors at priority 10. We run at priority 50 and attempt
183 + * our own Bearer-token validation. If it succeeds, we clear the stale error —
184 + * our authentication wins. (jwt-authentication-for-wp-rest-api surfaces its
185 + * error through rest_pre_dispatch instead; see clear_third_party_jwt_error().)
274 186 *
275 187 * @param mixed $errors Authentication errors.
276 188 *
277 189 * @return mixed
@@ -284,19 +196,9 @@
284 196 if ( ! empty( $errors ) ) {
285 197 // Only clear errors that originate from JWT authentication plugins. Errors
286 198 // from other mechanisms (maintenance locks, IP restrictions, etc.) should
287 199 // be passed through even when the WCPOS Bearer token is valid.
288 - $is_jwt_plugin_error = is_wp_error( $errors ) && 0 === strpos( $errors->get_error_code(), 'jwt_auth_' );
289 -
290 - if ( $is_jwt_plugin_error && ! $this->authenticated_via_wcpos ) {
291 - $user_id = $this->authenticate( false );
292 - if ( $user_id && ! is_wp_error( $user_id ) ) {
293 - wp_set_current_user( $user_id );
294 - $this->authenticated_via_wcpos = true;
295 - }
296 - }
297 -
298 - if ( $this->authenticated_via_wcpos && $is_jwt_plugin_error ) {
200 + if ( $this->is_third_party_jwt_error( $errors ) && $this->ensure_authenticated_via_wcpos() ) {
299 201 return null;
300 202 }
301 203
302 204 return $errors;
@@ -302,20 +204,80 @@
302 204 return $errors;
303 205 }
304 206
305 207 // check if determine_current_user has been called.
306 - if ( ! $this->is_auth_checked ) {
307 - // Authentication hasn't occurred during `determine_current_user`, so check auth.
208 + if ( ! $this->is_auth_checked && $this->ensure_authenticated_via_wcpos() ) {
209 + // Authentication hadn't occurred during `determine_current_user`, but our token is valid.
210 + return true;
211 + }
212 +
213 + return $errors;
214 + }
215 +
216 + /**
217 + * Clear a third-party JWT plugin's stale error from the dispatch result.
218 + *
219 + * The plugin jwt-authentication-for-wp-rest-api (verified at 1.5.0) validates every
220 + * Bearer token in determine_current_user (priority 10) with its own secret. Ours fails,
221 + * so it stores a `jwt_auth_invalid_token` WP_Error and returns the user untouched;
222 + * our priority-20 filter then authenticates the request. The plugin later returns
223 + * that stored error from rest_pre_dispatch (priority 10, registered at
224 + * plugins_loaded), which replaces the dispatch result with a 403.
225 + *
226 + * Priority 50: after the plugin's callback, and after our own priority-10
227 + * permission gate, whose `woocommerce_pos_rest_*` errors must pass through untouched.
228 + *
229 + * Unlike rest_authentication_errors(), this never switches the current user: the
230 + * priority-10 gate and the core-order audit guard have already judged the user in
231 + * scope, so the error is cleared only when our token resolves to that same user.
232 + *
233 + * @param mixed $result Dispatch result, or null to not hijack the request.
234 + * @param WP_REST_Server $server Server instance.
235 + * @param WP_REST_Request $request Request used to generate the response.
236 + *
237 + * @return mixed
238 + */
239 + public function clear_third_party_jwt_error( $result, $server, $request ) {
240 + if ( ! $this->is_third_party_jwt_error( $result ) ) {
241 + return $result;
242 + }
243 +
244 + if ( ! $this->authenticated_via_wcpos ) {
308 245 $user_id = $this->authenticate( false );
246 + if ( $user_id && ! is_wp_error( $user_id ) && get_current_user_id() === (int) $user_id ) {
247 + $this->authenticated_via_wcpos = true;
248 + }
249 + }
250 +
251 + return $this->authenticated_via_wcpos ? null : $result;
252 + }
253 +
254 + /**
255 + * Whether a value is a WP_Error raised by a third-party JWT plugin (`jwt_auth_*`).
256 + *
257 + * @param mixed $maybe_error Value to inspect.
258 + *
259 + * @return bool
260 + */
261 + private function is_third_party_jwt_error( $maybe_error ): bool {
262 + return is_wp_error( $maybe_error ) && 0 === strpos( $maybe_error->get_error_code(), 'jwt_auth_' );
263 + }
264 +
265 + /**
266 + * Authenticate the request with its WCPOS Bearer token if that hasn't happened yet.
267 + *
268 + * @return bool True when the request is authenticated via a WCPOS-issued token.
269 + */
270 + private function ensure_authenticated_via_wcpos(): bool {
271 + if ( ! $this->authenticated_via_wcpos ) {
272 + $user_id = $this->authenticate( false );
309 273 if ( $user_id && ! is_wp_error( $user_id ) ) {
310 274 wp_set_current_user( $user_id );
311 275 $this->authenticated_via_wcpos = true;
312 -
313 - return true;
314 276 }
315 277 }
316 278
317 - return $errors;
279 + return $this->authenticated_via_wcpos;
318 280 }
319 281
320 282 /**
321 283 * Extract the Authorization Bearer token from the request.
@@ -322,26 +284,9 @@
322 284 *
323 285 * @return false|string
324 286 */
325 287 public function get_auth_header() {
326 - // Check if HTTP_AUTHORIZATION is set and not empty
327 - // (htaccess SetEnvIf can set an empty value when no header is present).
328 - if ( ! empty( $_SERVER['HTTP_AUTHORIZATION'] ) ) {
329 - return sanitize_text_field( wp_unslash( $_SERVER['HTTP_AUTHORIZATION'] ) );
330 - }
331 -
332 - // Check for alternative header in $_SERVER.
333 - if ( ! empty( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) ) {
334 - return sanitize_text_field( wp_unslash( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) );
335 - }
336 -
337 - // Check for authorization param in URL ($_GET).
338 - if ( ! empty( $_GET['authorization'] ) ) {
339 - return sanitize_text_field( wp_unslash( $_GET['authorization'] ) );
340 - }
341 -
342 - // Return false if none of the variables are set.
343 - return false;
288 + return Auth::instance()->get_auth_header();
344 289 }
345 290
346 291 /**
347 292 * Adds info to the WP REST API index response.
@@ -352,18 +297,14 @@
352 297 *
353 298 * @return WP_REST_Response
354 299 */
355 300 public function rest_index( WP_REST_Response $response ): WP_REST_Response {
356 - $uuid = get_option( 'woocommerce_pos_uuid' );
357 - if ( ! $uuid ) {
358 - $uuid = Uuid::uuid4()->toString();
359 - update_option( 'woocommerce_pos_uuid', $uuid );
360 - }
301 + $uuid = wcpos_get_site_uuid();
361 302 $response->data['uuid'] = $uuid;
362 303 $response->data['wp_version'] = get_bloginfo( 'version' );
363 304 $response->data['wc_version'] = WC()->version;
364 305 $response->data['wcpos_version'] = VERSION;
365 - $response->data['use_jwt_as_param'] = woocommerce_pos_get_settings( 'tools', 'use_jwt_as_param' );
306 + $response->data['use_jwt_as_param'] = SettingsService::instance()->use_jwt_as_param_enabled();
366 307
367 308 // Add WCPOS authentication endpoint to the response.
368 309 $response->data['authentication']['wcpos'] = array(
369 310 'endpoints' => array(
@@ -398,30 +339,79 @@
398 339 *
399 340 * @return mixed
400 341 */
401 342 public function rest_pre_dispatch( $result, $server, $request ) {
402 - if ( strpos( $request->get_route(), '/wcpos/v1/' ) !== 0 ) {
343 + if ( ! $this->route_classifier->in_wcpos_namespace( $request->get_route() ) ) {
403 344 return $result;
404 345 }
405 346
406 - // Baseline permission gate: all POS endpoints require access_woocommerce_pos.
347 + // Marker-gated on purpose (query var or header, NOT the rest_route arm,
348 + // which matches this namespace by construction): every real POS client,
349 + // old or new, carries the marker, while unmarked scanner traffic would
350 + // otherwise inflate the `channel: none` tail this telemetry exists to
351 + // measure (free#1752). The echo and auth lanes are excluded for the same
352 + // reason: they are the gate's carve-outs, and a protocol-2 client's
353 + // connect-time probes deliberately carry no signal — counting them would
354 + // stamp every modern client with a daily false `none` row.
355 + if ( 0 === stripos( $request->get_route(), '/wcpos/v2/' )
356 + && 1 !== preg_match( '#^/wcpos/v2/(?:echo$|auth(?:/|$))#i', $request->get_route() )
357 + && ( wcpos_request( 'query_var' ) || wcpos_request( 'header' ) ) ) {
358 + try {
359 + Client_Signal::record( $request );
360 + } catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- Telemetry failures are deliberately ignored.
361 + // Telemetry must never interrupt a POS request.
362 + }
363 + }
364 +
365 + // Latch the till's store scope for the whole request (pro#425). Set
366 + // unconditionally — including to null — so a scope never leaks from one
367 + // dispatch into the next. Inner `wc/v3` forwards do not reach this line
368 + // (they are outside the WCPOS namespace), which is exactly right: the
369 + // OUTER request owns the scope and stamps it onto the inner ones.
370 + \WCPOS\WooCommercePOS\Sync\Store_Scope::set_current(
371 + \WCPOS\WooCommercePOS\Sync\Store_Scope::resolve( $request )
372 + );
373 +
374 + // CORS preflights carry no credentials (browsers strip Authorization from OPTIONS),
375 + // so the permission gate must never answer them with 401 — a non-2xx preflight blocks
376 + // every cross-origin standalone client from the entire namespace. WP core serves
377 + // OPTIONS with route metadata and Rest_Cors::rest_pre_serve_request adds the CORS headers.
378 + if ( 'OPTIONS' === $request->get_method() ) {
379 + return $result;
380 + }
381 +
382 + // Baseline permission gate: POS endpoints require access_woocommerce_pos; the three
383 + // sync admin operations instead use their route-level manage_woocommerce check.
407 384 // Exempt public auth, printer-token polling, and authenticated receipt denials that need
408 385 // the receipt-specific error code.
409 386 $route = $request->get_route();
410 - $has_route_specific_permission_error = is_user_logged_in() && 0 === strpos( $route, '/wcpos/v1/receipts/' );
411 - $is_public_auth_route = \in_array( $route, array( '/wcpos/v1/auth/test', '/wcpos/v1/auth/refresh' ), true );
412 - $is_printer_token_route = \in_array( $route, array( '/wcpos/v1/print-jobs/cloudprnt', '/wcpos/v1/print-jobs/epson-sdp' ), true )
413 - || 0 === strpos( $route, '/wcpos/v1/print-jobs/cloudprnt/' )
414 - || 0 === strpos( $route, '/wcpos/v1/print-jobs/epson-sdp/' );
415 - $is_relay_verification_route = '/wcpos/v1/print-jobs/relay-verification' === $route;
387 + $has_route_specific_permission_error = is_user_logged_in() && $this->route_classifier->is_permission_error_passthrough( $route );
388 + $is_public_auth_route = $this->route_classifier->is_public( $route );
389 + $is_printer_token_route = $this->route_classifier->is_printer_token( $route );
390 + $is_sync_admin_route = is_user_logged_in() && current_user_can( 'manage_woocommerce' ) && $this->route_classifier->is_admin_op( $route );
416 391
417 - if ( ! $is_public_auth_route && ! $has_route_specific_permission_error && ! $is_printer_token_route && ! $is_relay_verification_route ) {
392 + if ( ! $is_public_auth_route && ! $has_route_specific_permission_error && ! $is_printer_token_route && ! $is_sync_admin_route ) {
418 393 if ( ! current_user_can( 'access_woocommerce_pos' ) ) {
419 394 if ( ! is_user_logged_in() ) {
395 + $data = array( 'status' => 401 );
396 + if ( null !== $this->auth_error ) {
397 + $data['reason'] = $this->auth_error->get_error_code();
398 + if ( 'woocommerce_pos_auth_token_expired' !== $data['reason'] ) {
399 + Logger::warning(
400 + 'POS request refused: ' . $data['reason'] . ' — ' . $this->auth_error->get_error_message(),
401 + array(
402 + 'route' => $route,
403 + 'method' => $request->get_method(),
404 + 'reason' => $data['reason'],
405 + )
406 + );
407 + }
408 + }
409 +
420 410 return new \WP_Error(
421 411 'woocommerce_pos_rest_unauthorized',
422 412 __( 'Authentication required.', 'woocommerce-pos' ),
423 - array( 'status' => 401 )
413 + $data
424 414 );
425 415 }
426 416
427 417 return new \WP_Error(
@@ -433,8 +423,16 @@
433 423 }
434 424
435 425 $max_length = 10000;
436 426
427 + // The sync sub-surface speaks its own wire contract (include = raw id
428 + // list validated by its controllers); the wcpos_include/exclude rewrite
429 + // below is a legacy extended-WC-controller workaround and must not
430 + // mangle sync routes.
431 + if ( $this->route_classifier->is_rewrite_exempt( $route ) ) {
432 + return $result;
433 + }
434 +
437 435 // Process 'include' parameter.
438 436 $include = $request->get_param( 'include' );
439 437 if ( $include ) {
440 438 $processed_include = $this->shorten_param_array( $include, $max_length );
@@ -453,8 +451,37 @@
453 451 return $result;
454 452 }
455 453
456 454 /**
455 + * Add the server pressure bucket to WCPOS REST responses.
456 + *
457 + * @param mixed $response REST response.
458 + * @param WP_REST_Server $server REST server.
459 + * @param WP_REST_Request $request REST request.
460 + *
461 + * @return mixed
462 + */
463 + public function rest_post_dispatch( $response, $server, $request ) {
464 + if ( is_wp_error( $response ) ) {
465 + return $response;
466 + }
467 +
468 + try {
469 + if ( ! $response instanceof WP_HTTP_Response || ! $this->route_classifier->in_wcpos_namespace( $request->get_route() ) ) {
470 + return $response;
471 + }
472 + $pressure_bucket = API\V2\Ping::pressure_bucket();
473 + if ( null !== $pressure_bucket ) {
474 + $response->header( 'X-WCPOS-Pressure', $pressure_bucket );
475 + }
476 + } catch ( \Throwable $e ) {
477 + return $response;
478 + }
479 +
480 + return $response;
481 + }
482 +
483 + /**
457 484 * Filters the REST API dispatch request result.
458 485 *
459 486 * @param mixed $dispatch_result Dispatch result, will be used if not empty.
460 487 * @param WP_REST_Request $request Request used to generate the response.
@@ -463,10 +490,11 @@
463 490 *
464 491 * @return mixed
465 492 */
466 493 public function rest_dispatch_request( $dispatch_result, $request, $route, $handler ) {
467 - // Only process wcpos/v1 routes.
468 - if ( ! isset( $this->route_map[ $route ] ) ) {
494 + // Only process mapped WCPOS routes.
495 + $controller = $this->registry->controller_for_route( $route );
496 + if ( null === $controller ) {
469 497 return $dispatch_result;
470 498 }
471 499
472 500 /*
@@ -485,12 +513,9 @@
485 513 @ini_set( 'display_errors', '0' ); // phpcs:ignore WordPress.PHP.IniSet.display_errors_Disallowed -- intentionally disabling error display for POS API responses.
486 514 @ini_set( 'precision', '10' );
487 515 @ini_set( 'serialize_precision', '10' );
488 516
489 - $key = $this->route_map[ $route ];
490 - $controller = $this->controllers[ $key ] ?? null;
491 -
492 - if ( $controller && method_exists( $controller, 'wcpos_dispatch_request' ) ) {
517 + if ( method_exists( $controller, 'wcpos_dispatch_request' ) ) {
493 518 return $controller->wcpos_dispatch_request( $dispatch_result, $request, $route, $handler );
494 519 }
495 520
496 521 return $dispatch_result;
@@ -542,33 +567,16 @@
542 567 *
543 568 * @return false|int|\WP_Error
544 569 */
545 570 private function authenticate( $user_id ) {
546 - // check if there is an auth header.
547 - $auth_header = $this->get_auth_header();
548 - if ( ! \is_string( $auth_header ) ) {
549 - return $user_id;
550 - }
571 + // Per-request: never let a previous authentication's verdict describe this one.
572 + $this->auth_error = null;
573 + $authenticated_user_id = Auth::instance()->authenticate_request();
551 574
552 - // Extract Bearer token from Authorization Header.
553 - list($token) = sscanf( $auth_header, 'Bearer %s' );
554 -
555 - if ( $token ) {
556 - $auth_service = Auth::instance();
557 - $decoded_token = $auth_service->validate_token( $token );
558 -
559 - // Check if validate_token returned WP_Error and user_id is null.
560 - if ( is_wp_error( $decoded_token ) && false === $user_id ) {
561 - return $decoded_token;
562 - }
563 -
564 - // If the token is valid, set the user_id.
565 - if ( ! is_wp_error( $decoded_token ) ) {
566 - $user_id = $decoded_token->data->user->id;
567 -
568 - return absint( $user_id );
569 - }
575 + if ( is_wp_error( $authenticated_user_id ) ) {
576 + $this->auth_error = $authenticated_user_id;
577 + return false === $user_id ? $authenticated_user_id : $user_id;
570 578 }
571 579
572 - return $user_id;
580 + return false === $authenticated_user_id ? $user_id : $authenticated_user_id;
573 581 }
574 582 }