PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.18
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.18
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 +311 -165 1.9.151.10.18 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,11 @@
9 9 */
10 10
11 11 namespace WCPOS\WooCommercePOS;
12 12
13 -use Ramsey\Uuid\Uuid;
14 13 use WCPOS\WooCommercePOS\Services\Auth;
14 +use WCPOS\WooCommercePOS\Services\Client_Signal;
15 +use WCPOS\WooCommercePOS\Services\Settings as SettingsService;
15 16 use WP_HTTP_Response;
16 17 use WP_REST_Request;
17 18 use WP_REST_Response;
18 19 use WP_REST_Server;
@@ -21,8 +22,13 @@
21 22 * API class.
22 23 */
23 24 class API {
24 25 /**
26 + * WCPOS REST API namespaces.
27 + */
28 + public const ROUTE_NAMESPACES = array( 'wcpos/v1', 'wcpos/v2' );
29 +
30 + /**
25 31 * WCPOS REST API namespaces and endpoints.
26 32 *
27 33 * @var array
28 34 */
@@ -36,8 +42,15 @@
36 42 */
37 43 protected $route_map = array();
38 44
39 45 /**
46 + * Route permission-gate classifier.
47 + *
48 + * @var API\Route_Classifier
49 + */
50 + protected $route_classifier;
51 +
52 + /**
40 53 * Flag to check if authentication has been checked.
41 54 *
42 55 * @var bool
43 56 */
@@ -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,14 +84,43 @@
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 /**
93 + * Get the WCPOS REST API namespaces.
94 + *
95 + * @return string[] REST API namespaces.
96 + */
97 + public function get_route_namespaces(): array {
98 + /**
99 + * Filter the list of namespaces used in the WCPOS REST API.
100 + *
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.
106 + *
107 + * @since 1.10.0
108 + *
109 + * @param string[] $namespaces REST API namespaces.
110 + */
111 + $namespaces = apply_filters( 'woocommerce_pos_rest_namespaces', self::ROUTE_NAMESPACES );
112 +
113 + return array_values( array_unique( array_merge( self::ROUTE_NAMESPACES, (array) $namespaces ) ) );
114 + }
115 +
116 + /**
82 117 * Register routes for all controllers.
83 118 */
84 119 public function register_routes(): void {
120 + $route_namespaces = $this->get_route_namespaces();
121 + $this->route_classifier = new API\Route_Classifier( $route_namespaces );
122 +
85 123 /**
86 124 * Filter the list of controller classes used in the WCPOS REST API.
87 125 *
88 126 * This filter allows customizing or extending the set of controller classes that handle
@@ -87,8 +125,9 @@
87 125 *
88 126 * This filter allows customizing or extending the set of controller classes that handle
89 127 * REST API routes for the WCPOS. By filtering these controllers, plugins can
90 128 * modify existing endpoints or add new controllers for additional functionality.
129 + * Core legacy controllers use their versioned WCPOS\WooCommercePOS\API\V1 FQCNs.
91 130 *
92 131 * @since 1.5.0
93 132 *
94 133 * @param array $controllers Associative array of controller identifiers to their corresponding class names.
@@ -109,75 +148,135 @@
109 148 $classes = apply_filters(
110 149 'woocommerce_pos_rest_api_controllers',
111 150 array(
112 151 // 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,
152 + 'auth' => API\V1\Auth::class,
153 + 'settings' => API\V1\Settings::class,
154 + 'cashier' => API\V1\Cashier::class,
155 + 'templates' => API\V1\Templates_Controller::class,
156 + 'receipts' => API\V1\Receipts_Controller::class,
157 + 'print_jobs' => API\V1\Print_Jobs_Controller::class,
119 158
120 159 // 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,
160 + 'stores' => API\V1\Stores::class,
161 + 'extensions' => API\V1\Extensions::class,
162 + 'logs' => API\V1\Logs::class,
163 + 'payment_gateways' => API\V1\Payment_Gateways::class,
164 + 'gateway_bootstrap' => API\V1\Gateway_Bootstrap_Controller::class,
165 + 'checkout' => API\V1\Checkout_Controller::class,
127 166
128 167 // 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,
168 + 'products' => API\V1\Products_Controller::class,
169 + 'product_variations' => API\V1\Product_Variations_Controller::class,
170 + 'orders' => API\V1\Orders_Controller::class,
171 + 'customers' => API\V1\Customers_Controller::class,
172 + 'product_tags' => API\V1\Product_Tags_Controller::class,
173 + 'product_categories' => API\V1\Product_Categories_Controller::class,
174 + 'product_brands' => API\V1\Product_Brands_Controller::class,
175 + 'coupons' => API\V1\Coupons_Controller::class,
176 + 'taxes' => API\V1\Taxes_Controller::class,
177 + 'shipping_methods' => API\V1\Shipping_Methods_Controller::class,
178 + 'tax_classes' => API\V1\Tax_Classes_Controller::class,
179 + 'order_statuses' => API\V1\Data_Order_Statuses_Controller::class,
141 180 )
142 181 );
143 182
183 + /**
184 + * Filter the wcpos/v2 service pass-through controllers (additive to the
185 + * frozen v1 surface — the legacy data controllers stay v1-only).
186 + *
187 + * Extensions that replace a v1 service through
188 + * `woocommerce_pos_rest_api_controllers` must carry their service onto
189 + * the v2 surface here with their own pass-through subclass (override
190 + * `$namespace = 'wcpos/v2'`), exactly as core does — the v2 map is not
191 + * derived from the v1 map, so a v1 replacement alone leaves the v2
192 + * twin serving core behavior.
193 + *
194 + * @since 1.10.0
195 + *
196 + * @param array $controllers Associative array of v2 service controller class names.
197 + */
198 + $v2_classes = apply_filters(
199 + 'woocommerce_pos_rest_api_v2_controllers',
200 + array(
201 + 'ping' => API\V2\Ping::class,
202 + 'echo_probe' => API\V2\Echo_Probe::class,
203 + 'site' => API\V2\Site::class,
204 + 'auth' => API\V2\Auth::class,
205 + 'settings' => API\V2\Settings::class,
206 + 'cashier' => API\V2\Cashier::class,
207 + 'templates' => API\V2\Templates_Controller::class,
208 + 'receipts' => API\V2\Receipts_Controller::class,
209 + 'print_jobs' => API\V2\Print_Jobs_Controller::class,
210 + 'stores' => API\V2\Stores::class,
211 + 'extensions' => API\V2\Extensions::class,
212 + 'logs' => API\V2\Logs::class,
213 + 'payment_gateways' => API\V2\Payment_Gateways::class,
214 + 'gateway_bootstrap' => API\V2\Gateway_Bootstrap_Controller::class,
215 + 'checkout' => API\V2\Checkout_Controller::class,
216 + 'order_email' => API\V2\Order_Email_Controller::class,
217 + 'shipping_methods' => API\V2\Shipping_Methods_Controller::class,
218 + 'tax_classes' => API\V2\Tax_Classes_Controller::class,
219 + 'order_statuses' => API\V2\Data_Order_Statuses_Controller::class,
220 + )
221 + );
222 + foreach ( $v2_classes as $key => $class ) {
223 + $classes[ 'v2-' . $key ] = $class;
224 + }
225 + $legacy_classifications = array(
226 + 'auth' => array( 'public' => array( '/wcpos/v1/auth/test', '/wcpos/v1/auth/refresh' ) ),
227 + 'print_jobs' => array( 'printer_token' => array( '/wcpos/v1/print-jobs/cloudprnt', '/wcpos/v1/print-jobs/epson-sdp' ) ),
228 + 'receipts' => array( 'permission_error_passthrough' => array( '/wcpos/v1/receipts/' ) ),
229 + );
230 +
144 231 foreach ( $classes as $key => $class ) {
145 232 if ( class_exists( $class ) ) {
146 233 $this->controllers[ $key ] = new $class();
147 234 $this->controllers[ $key ]->register_routes();
235 +
236 + if ( method_exists( $this->controllers[ $key ], 'wcpos_route_classifications' ) ) {
237 + $this->route_classifier->merge( $this->controllers[ $key ]->wcpos_route_classifications() );
238 + } elseif ( isset( $legacy_classifications[ $key ] ) ) {
239 + $this->route_classifier->merge( $legacy_classifications[ $key ] );
240 + }
148 241 }
149 242 }
150 243
244 + // Sync classifications are independent of feature-gated route registration.
245 + $this->route_classifier->merge( Sync\Api::route_classifications() );
246 +
151 247 // Build route map for use in rest_dispatch_request().
152 248 $rest_server = rest_get_server();
153 - $all_routes = $rest_server->get_routes( 'wcpos/v1' );
154 249
155 - foreach ( $all_routes as $route_pattern => $route_handlers ) {
156 - foreach ( $route_handlers as $route_handler ) {
157 - $callback = $route_handler['callback'] ?? null;
250 + foreach ( $route_namespaces as $route_namespace ) {
251 + $all_routes = $rest_server->get_routes( $route_namespace );
158 252
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 - }
253 + foreach ( $all_routes as $route_pattern => $route_handlers ) {
254 + foreach ( $route_handlers as $route_handler ) {
255 + $callback = $route_handler['callback'] ?? null;
169 256
170 - if ( ! $controller_obj ) {
171 - continue;
172 - }
257 + // Extract the controller object from the callback.
258 + $controller_obj = null;
259 + if ( \is_array( $callback ) && isset( $callback[0] ) && \is_object( $callback[0] ) ) {
260 + $controller_obj = $callback[0];
261 + } elseif ( $callback instanceof \Closure ) {
262 + // WC 10.5+ RestApiCache wraps callbacks in closures.
263 + // Use reflection to extract the bound $this.
264 + $ref = new \ReflectionFunction( $callback );
265 + $controller_obj = $ref->getClosureThis();
266 + }
173 267
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;
268 + if ( ! $controller_obj ) {
269 + continue;
179 270 }
271 +
272 + // Find which controller key this object belongs to.
273 + foreach ( $this->controllers as $key => $registered_controller ) {
274 + if ( $controller_obj === $registered_controller ) {
275 + $this->route_map[ $route_pattern ] = $key;
276 + break;
277 + }
278 + }
180 279 }
181 280 }
182 281 }
183 282 }
@@ -182,42 +281,8 @@
182 281 }
183 282 }
184 283
185 284 /**
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
191 - */
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';
196 -
197 - return $allow_headers;
198 - }
199 -
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;
217 - }
218 -
219 - /**
220 285 * Check request for any login tokens.
221 286 *
222 287 * Runs at priority 20, after other plugins (e.g. third-party JWT plugins at
223 288 * priority 10) have had a chance to authenticate the user. If another plugin
@@ -265,13 +330,14 @@
265 330 * 1. WooCommerce issue #26847: determine_current_user may not be called when
266 331 * WordPress has already cached the current user. We attempt auth here as a
267 332 * fallback.
268 333 *
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.
334 + * 2. JWT plugin conflict: a third-party JWT plugin sees our Bearer token, fails
335 + * to validate it with its own secret, and returns a WP_Error via
336 + * rest_authentication_errors at priority 10. We run at priority 50 and attempt
337 + * our own Bearer-token validation. If it succeeds, we clear the stale error —
338 + * our authentication wins. (jwt-authentication-for-wp-rest-api surfaces its
339 + * error through rest_pre_dispatch instead; see clear_third_party_jwt_error().)
274 340 *
275 341 * @param mixed $errors Authentication errors.
276 342 *
277 343 * @return mixed
@@ -284,19 +350,9 @@
284 350 if ( ! empty( $errors ) ) {
285 351 // Only clear errors that originate from JWT authentication plugins. Errors
286 352 // from other mechanisms (maintenance locks, IP restrictions, etc.) should
287 353 // 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 ) {
354 + if ( $this->is_third_party_jwt_error( $errors ) && $this->ensure_authenticated_via_wcpos() ) {
299 355 return null;
300 356 }
301 357
302 358 return $errors;
@@ -302,20 +358,80 @@
302 358 return $errors;
303 359 }
304 360
305 361 // 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.
362 + if ( ! $this->is_auth_checked && $this->ensure_authenticated_via_wcpos() ) {
363 + // Authentication hadn't occurred during `determine_current_user`, but our token is valid.
364 + return true;
365 + }
366 +
367 + return $errors;
368 + }
369 +
370 + /**
371 + * Clear a third-party JWT plugin's stale error from the dispatch result.
372 + *
373 + * The plugin jwt-authentication-for-wp-rest-api (verified at 1.5.0) validates every
374 + * Bearer token in determine_current_user (priority 10) with its own secret. Ours fails,
375 + * so it stores a `jwt_auth_invalid_token` WP_Error and returns the user untouched;
376 + * our priority-20 filter then authenticates the request. The plugin later returns
377 + * that stored error from rest_pre_dispatch (priority 10, registered at
378 + * plugins_loaded), which replaces the dispatch result with a 403.
379 + *
380 + * Priority 50: after the plugin's callback, and after our own priority-10
381 + * permission gate, whose `woocommerce_pos_rest_*` errors must pass through untouched.
382 + *
383 + * Unlike rest_authentication_errors(), this never switches the current user: the
384 + * priority-10 gate and the core-order audit guard have already judged the user in
385 + * scope, so the error is cleared only when our token resolves to that same user.
386 + *
387 + * @param mixed $result Dispatch result, or null to not hijack the request.
388 + * @param WP_REST_Server $server Server instance.
389 + * @param WP_REST_Request $request Request used to generate the response.
390 + *
391 + * @return mixed
392 + */
393 + public function clear_third_party_jwt_error( $result, $server, $request ) {
394 + if ( ! $this->is_third_party_jwt_error( $result ) ) {
395 + return $result;
396 + }
397 +
398 + if ( ! $this->authenticated_via_wcpos ) {
308 399 $user_id = $this->authenticate( false );
400 + if ( $user_id && ! is_wp_error( $user_id ) && get_current_user_id() === (int) $user_id ) {
401 + $this->authenticated_via_wcpos = true;
402 + }
403 + }
404 +
405 + return $this->authenticated_via_wcpos ? null : $result;
406 + }
407 +
408 + /**
409 + * Whether a value is a WP_Error raised by a third-party JWT plugin (`jwt_auth_*`).
410 + *
411 + * @param mixed $maybe_error Value to inspect.
412 + *
413 + * @return bool
414 + */
415 + private function is_third_party_jwt_error( $maybe_error ): bool {
416 + return is_wp_error( $maybe_error ) && 0 === strpos( $maybe_error->get_error_code(), 'jwt_auth_' );
417 + }
418 +
419 + /**
420 + * Authenticate the request with its WCPOS Bearer token if that hasn't happened yet.
421 + *
422 + * @return bool True when the request is authenticated via a WCPOS-issued token.
423 + */
424 + private function ensure_authenticated_via_wcpos(): bool {
425 + if ( ! $this->authenticated_via_wcpos ) {
426 + $user_id = $this->authenticate( false );
309 427 if ( $user_id && ! is_wp_error( $user_id ) ) {
310 428 wp_set_current_user( $user_id );
311 429 $this->authenticated_via_wcpos = true;
312 -
313 - return true;
314 430 }
315 431 }
316 432
317 - return $errors;
433 + return $this->authenticated_via_wcpos;
318 434 }
319 435
320 436 /**
321 437 * Extract the Authorization Bearer token from the request.
@@ -322,26 +438,9 @@
322 438 *
323 439 * @return false|string
324 440 */
325 441 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;
442 + return Auth::instance()->get_auth_header();
344 443 }
345 444
346 445 /**
347 446 * Adds info to the WP REST API index response.
@@ -352,18 +451,14 @@
352 451 *
353 452 * @return WP_REST_Response
354 453 */
355 454 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 - }
455 + $uuid = wcpos_get_site_uuid();
361 456 $response->data['uuid'] = $uuid;
362 457 $response->data['wp_version'] = get_bloginfo( 'version' );
363 458 $response->data['wc_version'] = WC()->version;
364 459 $response->data['wcpos_version'] = VERSION;
365 - $response->data['use_jwt_as_param'] = woocommerce_pos_get_settings( 'tools', 'use_jwt_as_param' );
460 + $response->data['use_jwt_as_param'] = SettingsService::instance()->use_jwt_as_param_enabled();
366 461
367 462 // Add WCPOS authentication endpoint to the response.
368 463 $response->data['authentication']['wcpos'] = array(
369 464 'endpoints' => array(
@@ -398,24 +493,58 @@
398 493 *
399 494 * @return mixed
400 495 */
401 496 public function rest_pre_dispatch( $result, $server, $request ) {
402 - if ( strpos( $request->get_route(), '/wcpos/v1/' ) !== 0 ) {
497 + if ( ! $this->route_classifier->in_wcpos_namespace( $request->get_route() ) ) {
403 498 return $result;
404 499 }
405 500
406 - // Baseline permission gate: all POS endpoints require access_woocommerce_pos.
501 + // Marker-gated on purpose (query var or header, NOT the rest_route arm,
502 + // which matches this namespace by construction): every real POS client,
503 + // old or new, carries the marker, while unmarked scanner traffic would
504 + // otherwise inflate the `channel: none` tail this telemetry exists to
505 + // measure (free#1752). The echo and auth lanes are excluded for the same
506 + // reason: they are the gate's carve-outs, and a protocol-2 client's
507 + // connect-time probes deliberately carry no signal — counting them would
508 + // stamp every modern client with a daily false `none` row.
509 + if ( 0 === stripos( $request->get_route(), '/wcpos/v2/' )
510 + && 1 !== preg_match( '#^/wcpos/v2/(?:echo$|auth(?:/|$))#i', $request->get_route() )
511 + && ( wcpos_request( 'query_var' ) || wcpos_request( 'header' ) ) ) {
512 + try {
513 + Client_Signal::record( $request );
514 + } catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- Telemetry failures are deliberately ignored.
515 + // Telemetry must never interrupt a POS request.
516 + }
517 + }
518 +
519 + // Latch the till's store scope for the whole request (pro#425). Set
520 + // unconditionally — including to null — so a scope never leaks from one
521 + // dispatch into the next. Inner `wc/v3` forwards do not reach this line
522 + // (they are outside the WCPOS namespace), which is exactly right: the
523 + // OUTER request owns the scope and stamps it onto the inner ones.
524 + \WCPOS\WooCommercePOS\Sync\Store_Scope::set_current(
525 + \WCPOS\WooCommercePOS\Sync\Store_Scope::resolve( $request )
526 + );
527 +
528 + // CORS preflights carry no credentials (browsers strip Authorization from OPTIONS),
529 + // so the permission gate must never answer them with 401 — a non-2xx preflight blocks
530 + // every cross-origin standalone client from the entire namespace. WP core serves
531 + // OPTIONS with route metadata and Rest_Cors::rest_pre_serve_request adds the CORS headers.
532 + if ( 'OPTIONS' === $request->get_method() ) {
533 + return $result;
534 + }
535 +
536 + // Baseline permission gate: POS endpoints require access_woocommerce_pos; the three
537 + // sync admin operations instead use their route-level manage_woocommerce check.
407 538 // Exempt public auth, printer-token polling, and authenticated receipt denials that need
408 539 // the receipt-specific error code.
409 540 $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;
541 + $has_route_specific_permission_error = is_user_logged_in() && $this->route_classifier->is_permission_error_passthrough( $route );
542 + $is_public_auth_route = $this->route_classifier->is_public( $route );
543 + $is_printer_token_route = $this->route_classifier->is_printer_token( $route );
544 + $is_sync_admin_route = is_user_logged_in() && current_user_can( 'manage_woocommerce' ) && $this->route_classifier->is_admin_op( $route );
416 545
417 - if ( ! $is_public_auth_route && ! $has_route_specific_permission_error && ! $is_printer_token_route && ! $is_relay_verification_route ) {
546 + if ( ! $is_public_auth_route && ! $has_route_specific_permission_error && ! $is_printer_token_route && ! $is_sync_admin_route ) {
418 547 if ( ! current_user_can( 'access_woocommerce_pos' ) ) {
419 548 if ( ! is_user_logged_in() ) {
420 549 return new \WP_Error(
421 550 'woocommerce_pos_rest_unauthorized',
@@ -433,8 +562,16 @@
433 562 }
434 563
435 564 $max_length = 10000;
436 565
566 + // The sync sub-surface speaks its own wire contract (include = raw id
567 + // list validated by its controllers); the wcpos_include/exclude rewrite
568 + // below is a legacy extended-WC-controller workaround and must not
569 + // mangle sync routes.
570 + if ( $this->route_classifier->is_rewrite_exempt( $route ) ) {
571 + return $result;
572 + }
573 +
437 574 // Process 'include' parameter.
438 575 $include = $request->get_param( 'include' );
439 576 if ( $include ) {
440 577 $processed_include = $this->shorten_param_array( $include, $max_length );
@@ -453,8 +590,37 @@
453 590 return $result;
454 591 }
455 592
456 593 /**
594 + * Add the server pressure bucket to WCPOS REST responses.
595 + *
596 + * @param mixed $response REST response.
597 + * @param WP_REST_Server $server REST server.
598 + * @param WP_REST_Request $request REST request.
599 + *
600 + * @return mixed
601 + */
602 + public function rest_post_dispatch( $response, $server, $request ) {
603 + if ( is_wp_error( $response ) ) {
604 + return $response;
605 + }
606 +
607 + try {
608 + if ( ! $response instanceof WP_HTTP_Response || ! $this->route_classifier->in_wcpos_namespace( $request->get_route() ) ) {
609 + return $response;
610 + }
611 + $pressure_bucket = API\V2\Ping::pressure_bucket();
612 + if ( null !== $pressure_bucket ) {
613 + $response->header( 'X-WCPOS-Pressure', $pressure_bucket );
614 + }
615 + } catch ( \Throwable $e ) {
616 + return $response;
617 + }
618 +
619 + return $response;
620 + }
621 +
622 + /**
457 623 * Filters the REST API dispatch request result.
458 624 *
459 625 * @param mixed $dispatch_result Dispatch result, will be used if not empty.
460 626 * @param WP_REST_Request $request Request used to generate the response.
@@ -463,9 +629,9 @@
463 629 *
464 630 * @return mixed
465 631 */
466 632 public function rest_dispatch_request( $dispatch_result, $request, $route, $handler ) {
467 - // Only process wcpos/v1 routes.
633 + // Only process mapped WCPOS routes.
468 634 if ( ! isset( $this->route_map[ $route ] ) ) {
469 635 return $dispatch_result;
470 636 }
471 637
@@ -542,33 +708,13 @@
542 708 *
543 709 * @return false|int|\WP_Error
544 710 */
545 711 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 - }
712 + $authenticated_user_id = Auth::instance()->authenticate_request();
551 713
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 - }
714 + if ( is_wp_error( $authenticated_user_id ) ) {
715 + return false === $user_id ? $authenticated_user_id : $user_id;
570 716 }
571 717
572 - return $user_id;
718 + return false === $authenticated_user_id ? $user_id : $authenticated_user_id;
573 719 }
574 720 }