����JFIF��������� Mr.X
  
  __  __    __   __  _____      _            _          _____ _          _ _ 
 |  \/  |   \ \ / / |  __ \    (_)          | |        / ____| |        | | |
 | \  / |_ __\ V /  | |__) | __ ___   ____ _| |_ ___  | (___ | |__   ___| | |
 | |\/| | '__|> <   |  ___/ '__| \ \ / / _` | __/ _ \  \___ \| '_ \ / _ \ | |
 | |  | | |_ / . \  | |   | |  | |\ V / (_| | ||  __/  ____) | | | |  __/ | |
 |_|  |_|_(_)_/ \_\ |_|   |_|  |_| \_/ \__,_|\__\___| |_____/|_| |_|\___V 2.1
 if you need WebShell for Seo everyday contact me on Telegram
 Telegram Address : @jackleet
        
        
For_More_Tools: Telegram: @jackleet | Bulk Smtp support mail sender | Business Mail Collector | Mail Bouncer All Mail | Bulk Office Mail Validator | Html Letter private



Upload:

Command:

fistvdlb@216.73.216.24: ~ $
<?php
/**
 * REST API Mobile App QR Login controller.
 *
 * Handles requests to generate and exchange QR login tokens for direct mobile
 * app authentication via Application Passwords. Token generation is gated on
 * the `manage_woocommerce` capability (administrators and shop managers by
 * default).
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Admin\API;

use Automattic\WooCommerce\Admin\API\RateLimits\QRLoginRateLimits;

defined( 'ABSPATH' ) || exit;

/**
 * Mobile App QR Login controller.
 *
 * @internal
 */
class MobileAppQRLogin extends \WC_REST_Data_Controller {

	/**
	 * Endpoint namespace.
	 *
	 * @var string
	 */
	protected $namespace = 'wc-admin';

	/**
	 * Route base.
	 *
	 * @var string
	 */
	protected $rest_base = 'mobile-app';

	/**
	 * Token TTL in seconds (5 minutes).
	 */
	const TOKEN_TTL = 300;

	/**
	 * Transient prefix for QR login tokens.
	 */
	const TOKEN_TRANSIENT_PREFIX = '_wc_qr_login_token_';

	/**
	 * Max tokens per user per 15-minute window.
	 */
	const MAX_TOKENS_PER_WINDOW = 5;

	/**
	 * Max exchange attempts per valid token per 15-minute window.
	 */
	const MAX_EXCHANGE_ATTEMPTS = 10;

	/**
	 * Max invalid-token exchange attempts per IP per 15-minute window.
	 */
	const MAX_INVALID_EXCHANGE_ATTEMPTS = 100;

	/**
	 * Max invalid-token scan attempts per IP per 15-minute window.
	 */
	const MAX_INVALID_SCAN_ATTEMPTS = 100;

	/**
	 * Broad anonymous exchange abuse guard per IP per 15-minute window.
	 */
	const MAX_EXCHANGE_IP_ATTEMPTS = 1000;

	/**
	 * Option prefix for database-backed atomic token claims.
	 */
	const CLAIM_OPTION_PREFIX = '_wc_qr_login_claim_';

	/**
	 * Scan-claim option prefix. Independent from `CLAIM_OPTION_PREFIX` so the
	 * scan and exchange mutexes can't deadlock each other; they protect
	 * different write windows on the same token record.
	 */
	const SCAN_CLAIM_OPTION_PREFIX = '_wc_qr_login_scan_claim_';

	/**
	 * Approval-claim option prefix. Prevents concurrent number choices from
	 * racing the one-strike scanned -> approved/rejected transition.
	 */
	const APPROVE_CLAIM_OPTION_PREFIX = '_wc_qr_login_approve_claim_';

	/**
	 * Stable Application Passwords `app_id` for credentials issued by this
	 * flow. Lets administrators identify QR-issued credentials in the
	 * Application Passwords screen and revoke them in bulk.
	 */
	const APP_ID = '0b540e2f-86b7-4b8a-8e0c-f61e9bfbde59';

	/**
	 * Transient prefix for the "token consumed" record written after a successful
	 * exchange. The wc-admin UI polls a status endpoint that reads this so it can
	 * transition to a confirmation panel and surface the device that signed in.
	 */
	const CONSUMED_TRANSIENT_PREFIX = '_wc_qr_login_consumed_';

	/**
	 * Max status checks per user per 15-minute window. The polling client hits
	 * this every ~2.5s while a QR is on screen; 600/15min ≈ 40/min, comfortably
	 * above the polling rate but tight enough to short-circuit a misbehaving
	 * client or a credential-stuffing scan.
	 */
	const MAX_STATUS_CHECKS_PER_WINDOW = 600;

	/**
	 * Max revoke attempts per user per 15-minute window.
	 */
	const MAX_REVOKE_ATTEMPTS = 10;

	/**
	 * Whitelisted keys for the `device` payload sent by the mobile app on the
	 * scan call. Anything outside this set is dropped before storage.
	 *
	 * `brand` is Android-only (`Build.BRAND`, e.g. "google", "samsung"); iOS
	 * doesn't have a direct analogue and clients that don't have the field
	 * just leave it absent.
	 *
	 * @var string[]
	 */
	const DEVICE_PAYLOAD_KEYS = array( 'os', 'os_version', 'model', 'brand', 'app_version' );

	/**
	 * Maximum length (chars) for any individual sanitized device-payload field.
	 * Defends against accidental or hostile bloat ending up in transients and
	 * the Application Password name.
	 */
	const DEVICE_FIELD_MAX_LENGTH = 64;

	/**
	 * State machine values for the per-token record. Transitions are gated
	 * by an explicit `current_state` check at the top of each handler
	 * (scan/approve/exchange) so the only writers are the handlers themselves.
	 */
	const STATE_PENDING  = 'pending';
	const STATE_SCANNED  = 'scanned';
	const STATE_APPROVED = 'approved';
	const STATE_REJECTED = 'rejected';
	const STATE_EXPIRED  = 'expired';
	const STATE_CONSUMED = 'consumed';

	/**
	 * Pick window after the app scans a QR (seconds). The merchant has this
	 * long to tap the matching number on wc-admin before the session
	 * auto-rejects. Short enough to limit replay; long enough for a confused
	 * user to read the phone, find their browser, and click.
	 */
	const CHALLENGE_TTL_SECONDS = 90;

	/**
	 * Length (bytes pre-bin2hex) of the exchange-grant nonce minted on
	 * approval. The grant gates the final `/qr-login-exchange` call so an
	 * attacker who learned the token can't race the legit app to exchange
	 * after approval. 32 bytes = 64 hex chars = 256 bits of entropy.
	 */
	const EXCHANGE_GRANT_BYTES = 32;

	/**
	 * Invalid exchange grants allowed before the token is terminally rejected.
	 */
	const MAX_INVALID_GRANT_ATTEMPTS = 3;

	/**
	 * Transient prefix mapping `session_id` → `token_hash` so the mobile-side
	 * `/qr-login-session-status` poll can resolve a session id back to the
	 * underlying token record without exposing the original token to the
	 * polling channel.
	 */
	const SESSION_TRANSIENT_PREFIX = '_wc_qr_login_session_';

	/**
	 * Rate limit for /qr-login-scan (per IP per 15 min).
	 */
	const MAX_SCAN_PER_WINDOW = 10;

	/**
	 * Rate limit for /qr-login-approve (per user per 15 min).
	 */
	const MAX_APPROVE_PER_WINDOW = 20;

	/**
	 * Rate limit for /qr-login-session-status (per session id per 15 min).
	 *
	 * Accounts for ~2-s polling over a 90-s challenge window plus headroom.
	 */
	const MAX_SESSION_STATUS_PER_WINDOW = 60;

	/**
	 * Register routes.
	 *
	 * @return void
	 */
	public function register_routes() {
		// Generate a QR login token (requires authentication and `manage_woocommerce` capability).
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/qr-login-token',
			array(
				array(
					'methods'             => \WP_REST_Server::CREATABLE,
					'callback'            => array( $this, 'generate_token' ),
					'permission_callback' => array( $this, 'get_items_permissions_check' ),
				),
				'schema' => array( $this, 'get_public_item_schema' ),
			)
		);

		// Exchange a QR login token for Application Password (no authentication required).
		// The device payload is captured at /qr-login-scan time and sourced from the
		// approved record — the exchange call only needs the token + grant nonce.
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/qr-login-exchange',
			array(
				array(
					'methods'             => \WP_REST_Server::CREATABLE,
					'callback'            => array( $this, 'exchange_token' ),
					'permission_callback' => '__return_true',
					'args'                => array(
						'token'          => array(
							'required'          => true,
							'type'              => 'string',
							'sanitize_callback' => 'sanitize_text_field',
						),
						// Soft-required: the handler enforces presence + validity
						// via constant-time comparison and returns a clear
						// `invalid_exchange_grant` 412 if missing. We don't make
						// it `required: true` at the schema layer because that
						// would short-circuit earlier checks (HTTPS, invalid
						// token, rate limit) with a generic WP validation 400
						// before our diagnostic responses can fire.
						'exchange_grant' => array(
							'type'              => 'string',
							'sanitize_callback' => 'sanitize_text_field',
						),
					),
				),
				'schema' => array( $this, 'get_public_item_schema' ),
			)
		);

		// Poll for token status (consumed yet?). Used by wc-admin to transition
		// the modal from "QR shown" to "Signed in successfully on {device}".
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/qr-login-status',
			array(
				array(
					'methods'             => \WP_REST_Server::CREATABLE,
					'callback'            => array( $this, 'get_status' ),
					'permission_callback' => array( $this, 'get_items_permissions_check' ),
					'args'                => array(
						'token' => array(
							'required'          => true,
							'type'              => 'string',
							'sanitize_callback' => 'sanitize_text_field',
						),
					),
				),
				'schema' => array( $this, 'get_public_item_schema' ),
			)
		);

		// Revoke (delete) the Application Password issued by an exchange. The
		// user must own the AP — verified inside the callback via the WP API.
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/qr-login-revoke',
			array(
				array(
					'methods'             => \WP_REST_Server::DELETABLE,
					'callback'            => array( $this, 'revoke_password' ),
					'permission_callback' => array( $this, 'get_items_permissions_check' ),
					'args'                => array(
						'uuid' => array(
							'required'          => true,
							'type'              => 'string',
							'sanitize_callback' => 'sanitize_text_field',
						),
					),
				),
				'schema' => array( $this, 'get_public_item_schema' ),
			)
		);

		// Mobile app reports the QR was scanned. Server generates the
		// number-match challenge and returns the *real* number to the app
		// only. Public — token + capability flag are the auth.
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/qr-login-scan',
			array(
				array(
					'methods'             => \WP_REST_Server::CREATABLE,
					'callback'            => array( $this, 'scan_token' ),
					'permission_callback' => '__return_true',
					'args'                => array(
						'token'                    => array(
							'required'          => true,
							'type'              => 'string',
							'sanitize_callback' => 'sanitize_text_field',
						),
						// The device payload is required: it shows up in the
						// merchant's "match this number" device card, in the
						// Application Password name, and in the sign-in
						// notification email. Mobile clients always have these
						// fields available from the platform SDK (Build.MODEL,
						// UIDevice.current.model, etc.), so requiring the
						// payload at the protocol level keeps every downstream
						// surface honest.
						'device'                   => array(
							'required'   => true,
							'type'       => 'object',
							'properties' => array(
								'os'          => array( 'type' => 'string' ),
								'os_version'  => array( 'type' => 'string' ),
								'model'       => array( 'type' => 'string' ),
								'brand'       => array( 'type' => 'string' ),
								'app_version' => array( 'type' => 'string' ),
							),
						),
						// Capability flag the mobile app sets to advertise that
						// it implements the number-matching protocol. Reserved
						// for future protocol bumps that might gate behavior on
						// further capability bits.
						'supports_number_matching' => array(
							'required' => true,
							'type'     => 'boolean',
						),
					),
				),
				'schema' => array( $this, 'get_public_item_schema' ),
			)
		);

		// Merchant taps a number on wc-admin. Server validates against the
		// stored real number with hash_equals; correct → approved, wrong →
		// rejected (terminal, no retry).
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/qr-login-approve',
			array(
				array(
					'methods'             => \WP_REST_Server::CREATABLE,
					'callback'            => array( $this, 'approve_token' ),
					'permission_callback' => array( $this, 'get_items_permissions_check' ),
					'args'                => array(
						'token'  => array(
							'required'          => true,
							'type'              => 'string',
							'sanitize_callback' => 'sanitize_text_field',
						),
						'choice' => array(
							'required'          => true,
							'type'              => 'string',
							'sanitize_callback' => 'sanitize_text_field',
						),
					),
				),
				'schema' => array( $this, 'get_public_item_schema' ),
			)
		);

		// Mobile app polls this with the session id returned from /scan.
		// While in `scanned` we say so; on `approved` we hand over the
		// short-lived `exchange_grant` nonce required by the final
		// /qr-login-exchange call.
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/qr-login-session-status',
			array(
				array(
					'methods'             => \WP_REST_Server::READABLE,
					'callback'            => array( $this, 'get_session_status' ),
					'permission_callback' => '__return_true',
					'args'                => array(
						'session_id' => array(
							'required'          => true,
							'type'              => 'string',
							'sanitize_callback' => 'sanitize_text_field',
						),
						'token_hash' => array(
							'type'              => 'string',
							'sanitize_callback' => 'sanitize_text_field',
						),
					),
				),
				'schema' => array( $this, 'get_public_item_schema' ),
			)
		);

		// Cheap up-front capability check so wc-admin can render a permanently
		// disabled QR card (rather than spin up a token request that will fail)
		// when application passwords are unavailable on this site. Same gate as
		// the token endpoint so a subscriber cannot probe site configuration.
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/qr-login-availability',
			array(
				array(
					'methods'             => \WP_REST_Server::READABLE,
					'callback'            => array( $this, 'get_availability' ),
					'permission_callback' => array( $this, 'get_items_permissions_check' ),
				),
				'schema' => array( $this, 'get_public_item_schema' ),
			)
		);

		parent::register_routes();
	}

	/**
	 * Check whether the current user can generate a QR login token.
	 *
	 * Requires the `manage_woocommerce` capability, which covers administrators and
	 * shop managers out of the box. The check is deliberately explicit (not routed
	 * through `wc_rest_check_manager_permissions()`) so it cannot be loosened by the
	 * `woocommerce_rest_check_permissions` filter that other Admin API endpoints share.
	 *
	 * @param \WP_REST_Request<array<string, mixed>> $request The REST request (unused).
	 * @return \WP_Error|bool True if the user has the required capability, WP_Error otherwise.
	 */
	public function get_items_permissions_check( $request ) {
		unset( $request );
		// Parameter required by WP REST contract but unused here.

		if ( ! current_user_can( 'manage_woocommerce' ) ) {
			return new \WP_Error(
				'woocommerce_rest_cannot_view',
				__( 'Sorry, you are not allowed to generate a mobile app QR login token.', 'woocommerce' ),
				array( 'status' => rest_authorization_required_code() )
			);
		}

		return true;
	}

	/**
	 * Check if Application Passwords are available.
	 *
	 * @return bool
	 */
	private function are_application_passwords_available() {
		return function_exists( 'wp_is_application_passwords_available' )
			&& wp_is_application_passwords_available();
	}

	/**
	 * Return a REST response carrying WordPress' no-cache headers.
	 *
	 * @param array<string, mixed> $data Response payload.
	 * @return \WP_REST_Response
	 */
	private function rest_ensure_nocache_response( array $data ): \WP_REST_Response {
		$response = rest_ensure_response( $data );

		foreach ( wp_get_nocache_headers() as $header_name => $header_value ) {
			if ( false === $header_value ) {
				continue;
			}

			$response->header( $header_name, (string) $header_value );
		}

		return $response;
	}

	/**
	 * Reason codes returned by `/qr-login-availability` so wc-admin can
	 * tailor the disabled card to the specific cause.
	 */
	const AVAILABILITY_REASON_HTTPS_REQUIRED        = 'https_required';
	const AVAILABILITY_REASON_AP_UNSUPPORTED        = 'application_passwords_unsupported';
	const AVAILABILITY_REASON_AP_DISABLED_BY_FILTER = 'application_passwords_disabled_by_filter';

	/**
	 * Report whether QR login is currently available on this site.
	 *
	 * Lets wc-admin render a permanently-disabled QR card with the right
	 * explanation up-front, instead of mounting `<QRDirectLoginCode />`,
	 * spinning, calling `/qr-login-token`, and only then showing a generic
	 * error. The reason code is the heuristic best we can do without each
	 * security plugin self-identifying:
	 *
	 *  - `https_required` — `is_ssl()` is false or the raw/final `siteurl` is
	 *    `http://`. The most common cause is a local dev environment;
	 *    production sites without HTTPS can't use QR login at all.
	 *  - `application_passwords_unsupported` — WordPress core's own support
	 *    gate (`wp_is_application_passwords_supported()`) returns false.
	 *    Ships true on every modern WP host that has SSL or is local; false
	 *    here typically means the site is non-local + non-SSL.
	 *  - `application_passwords_disabled_by_filter` — the WP support gate
	 *    passes, but the `wp_is_application_passwords_available` filter
	 *    returns false. This is the case where a security plugin (Wordfence,
	 *    Solid Security, etc.) or a custom code snippet has explicitly
	 *    disabled application passwords. We can't name the exact source from
	 *    the filter alone; the docs link in the merchant-facing UI covers it.
	 *
	 * `nocache_headers()` so an upstream cache cannot pin a stale
	 * "unavailable" response for a site that just installed an HTTPS cert.
	 *
	 * @param \WP_REST_Request<array<string, mixed>> $request The REST request (unused).
	 * @return \WP_REST_Response
	 */
	public function get_availability( $request ): \WP_REST_Response {
		unset( $request );

		nocache_headers();

		$site_url     = $this->get_secure_site_url();
		$https_ok     = ! is_wp_error( $site_url );
		$ap_supported = function_exists( 'wp_is_application_passwords_supported' )
			&& wp_is_application_passwords_supported();
		$ap_available = $this->are_application_passwords_available();

		$https_ok  = is_ssl() && $https_ok;
		$available = $https_ok && $ap_available;
		$reason    = null;

		if ( ! $available ) {
			if ( ! $https_ok ) {
				$reason = self::AVAILABILITY_REASON_HTTPS_REQUIRED;
			} elseif ( ! $ap_supported ) {
				$reason = self::AVAILABILITY_REASON_AP_UNSUPPORTED;
			} else {
				$reason = self::AVAILABILITY_REASON_AP_DISABLED_BY_FILTER;
			}
		}

		return $this->rest_ensure_nocache_response(
			array(
				'available' => $available,
				'reason'    => $reason,
			)
		);
	}

	/**
	 * Check rate limit for token generation.
	 *
	 * @param int $user_id The user ID.
	 * @return bool True if within rate limit.
	 */
	private function check_generation_rate_limit( $user_id ) {
		return QRLoginRateLimits::consume( QRLoginRateLimits::BUCKET_GENERATION, (string) $user_id );
	}

	/**
	 * Broad anonymous abuse guard for token exchange.
	 *
	 * This intentionally has a high ceiling. It is only meant to slow obvious
	 * unauthenticated floods; valid-token and invalid-token traffic use separate
	 * lower buckets so a few random requests from a shared proxy IP cannot block
	 * legitimate QR login exchanges.
	 *
	 * @return bool True if within rate limit.
	 */
	private function check_exchange_ip_rate_limit() {
		return QRLoginRateLimits::consume( QRLoginRateLimits::BUCKET_EXCHANGE_IP, $this->get_client_ip() );
	}

	/**
	 * Check rate limit for random/nonexistent exchange tokens.
	 *
	 * @return bool True if within rate limit.
	 */
	private function check_invalid_exchange_rate_limit() {
		return QRLoginRateLimits::consume( QRLoginRateLimits::BUCKET_INVALID_EXCHANGE, $this->get_client_ip() );
	}

	/**
	 * Check rate limit for random/nonexistent scan tokens.
	 *
	 * @return bool True if within rate limit.
	 */
	private function check_invalid_scan_rate_limit() {
		return QRLoginRateLimits::consume( QRLoginRateLimits::BUCKET_INVALID_SCAN, $this->get_client_ip() );
	}

	/**
	 * Check rate limit for exchange attempts against a valid token.
	 *
	 * @param string $token_hash SHA-256 hash of the plaintext token.
	 * @return bool True if within rate limit.
	 */
	private function check_valid_exchange_rate_limit( $token_hash ) {
		return QRLoginRateLimits::consume( QRLoginRateLimits::BUCKET_VALID_EXCHANGE, $token_hash );
	}

	/**
	 * Get the client IP address used as the per-IP rate-limit key.
	 *
	 * Uses `REMOTE_ADDR` exclusively. We intentionally do not honor
	 * `HTTP_X_FORWARDED_FOR` here: the exchange endpoint is unauthenticated, and
	 * without a project-wide trusted-proxy list we cannot tell a legitimate
	 * proxy header from an attacker-supplied one. Trusting the first XFF value
	 * would let any client choose a fresh rate-limit bucket per request and
	 * bypass per-IP caps. On sites behind a CDN/load balancer that all clients
	 * share, REMOTE_ADDR is the proxy IP, so exchange uses broad IP throttling
	 * only as an abuse guard and relies on token-scoped buckets for security.
	 *
	 * @return string
	 */
	private function get_client_ip() {
		if ( ! empty( $_SERVER['REMOTE_ADDR'] ) ) {
			return sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) );
		}
		return '';
	}

	/**
	 * Build the option name used for a token exchange claim.
	 *
	 * @param string $token_hash SHA-256 hash of the plaintext token.
	 * @return string
	 */
	private function get_token_claim_key( $token_hash ) {
		return self::CLAIM_OPTION_PREFIX . $token_hash;
	}

	/**
	 * Atomically claim a token for exchange using the options table.
	 *
	 * `add_option()` is backed by a unique option_name constraint, so it works
	 * across PHP workers even on default installs without a persistent object
	 * cache. Stale claims are cleaned only if their stored value still matches
	 * the value this request observed, avoiding deletion of another worker's
	 * fresh claim.
	 *
	 * @param string $token_hash SHA-256 hash of the plaintext token.
	 * @param int    $expires_at Unix timestamp when the token expires.
	 * @return bool True if the claim was acquired.
	 */
	private function claim_token_for_exchange( $token_hash, $expires_at ) {
		return $this->claim_token_with_option_key(
			$this->get_token_claim_key( $token_hash ),
			$expires_at
		);
	}

	/**
	 * Atomically claim a token using an option key.
	 *
	 * @param string $claim_key Option key used as the claim mutex.
	 * @param int    $expires_at Unix timestamp when the token expires.
	 * @return bool True if the claim was acquired.
	 */
	private function claim_token_with_option_key( $claim_key, $expires_at ) {
		$claim_expires_at = max( time() + 30, (int) $expires_at );

		if ( add_option( $claim_key, (string) $claim_expires_at, '', false ) ) {
			return true;
		}

		$existing_expires_at = (int) get_option( $claim_key, 0 );
		if ( $existing_expires_at > 0 && $existing_expires_at <= time() ) {
			$this->delete_claim_if_value_matches( $claim_key, (string) $existing_expires_at );
			return add_option( $claim_key, (string) $claim_expires_at, '', false );
		}

		return false;
	}

	/**
	 * Delete a claim option only if it still has the value this request observed.
	 *
	 * @param string $claim_key            Option key used as the claim mutex.
	 * @param string $observed_claim_value Claim expiry value previously read from the option.
	 * @return bool True if the observed stale claim was deleted.
	 */
	private function delete_claim_if_value_matches( $claim_key, $observed_claim_value ) {
		global $wpdb;

		$result = $wpdb->query(
			$wpdb->prepare(
				"DELETE FROM {$wpdb->options} WHERE option_name = %s AND option_value = %s",
				$claim_key,
				$observed_claim_value
			)
		);

		if ( false === $result ) {
			wc_get_logger()->warning(
				sprintf(
					'QR login stale-claim cleanup query failed for %s: %s',
					$claim_key,
					$wpdb->last_error
				),
				array( 'source' => 'mobile-app-qr-login' )
			);
		}

		wp_cache_delete( $claim_key, 'options' );

		return (int) $result > 0;
	}

	/**
	 * Release a token exchange claim.
	 *
	 * @param string $token_hash SHA-256 hash of the plaintext token.
	 * @return void
	 */
	private function release_token_exchange_claim( $token_hash ) {
		delete_option( $this->get_token_claim_key( $token_hash ) );
	}

	/**
	 * Atomically claim a token for scan. Mirrors `claim_token_for_exchange()`
	 * (same `add_option()` unique-constraint mutex, same staleness recovery)
	 * but uses its own option key so the scan and exchange windows are
	 * independent.
	 *
	 * Without this, two concurrent `/qr-login-scan` requests both pass the
	 * `state === pending` gate, both write a fresh challenge, and the last
	 * writer wins — leaving the loser's session_id silently orphaned and
	 * pointing at the wrong challenge.
	 *
	 * @param string $token_hash SHA-256 hash of the plaintext token.
	 * @param int    $expires_at Unix timestamp when the token expires.
	 * @return bool True if the claim was acquired.
	 */
	private function claim_token_for_scan( $token_hash, $expires_at ) {
		return $this->claim_token_with_option_key(
			self::SCAN_CLAIM_OPTION_PREFIX . $token_hash,
			$expires_at
		);
	}

	/**
	 * Release a token scan claim owned by this request.
	 *
	 * @param string $token_hash SHA-256 hash of the plaintext token.
	 * @return void
	 */
	private function release_token_scan_claim( $token_hash ) {
		delete_option( self::SCAN_CLAIM_OPTION_PREFIX . $token_hash );
	}

	/**
	 * Atomically claim a token for approval.
	 *
	 * @param string $token_hash SHA-256 hash of the plaintext token.
	 * @param int    $expires_at Unix timestamp when the claim should expire.
	 * @return bool True if the claim was acquired.
	 */
	private function claim_token_for_approval( $token_hash, $expires_at ) {
		return $this->claim_token_with_option_key(
			self::APPROVE_CLAIM_OPTION_PREFIX . $token_hash,
			$expires_at
		);
	}

	/**
	 * Release a token approval claim owned by this request.
	 *
	 * @param string $token_hash SHA-256 hash of the plaintext token.
	 * @return void
	 */
	private function release_token_approval_claim( $token_hash ) {
		delete_option( self::APPROVE_CLAIM_OPTION_PREFIX . $token_hash );
	}

	/**
	 * Get the remaining storage TTL for a token record.
	 *
	 * @param array<string, mixed> $token_data Token record.
	 * @return int Remaining TTL in seconds.
	 */
	private function get_token_record_ttl( array $token_data ) {
		return max(
			1,
			isset( $token_data['expires_at'] ) ? (int) $token_data['expires_at'] - time() : self::TOKEN_TTL
		);
	}

	/**
	 * Delete the session-id to token-hash mapping for a token record.
	 *
	 * @param array<string, mixed> $token_data Token record.
	 * @return void
	 */
	private function delete_session_mapping_for_record( array $token_data ) {
		if ( empty( $token_data['challenge']['session_id'] ) ) {
			return;
		}

		delete_transient(
			self::SESSION_TRANSIENT_PREFIX . hash( 'sha256', (string) $token_data['challenge']['session_id'] )
		);
	}

	/**
	 * Validate that the configured site URL is HTTPS and return it.
	 *
	 * `is_ssl()` only tells us the current REQUEST is HTTPS — it says nothing about
	 * the canonical site URL WordPress is configured to advertise. `get_site_url()`
	 * itself is also insufficient because it passes its result through
	 * `set_url_scheme()`, which rewrites the scheme to match `is_ssl()` — so
	 * `get_site_url()` will return `https://…` whenever the request happens to be
	 * HTTPS, masking a stale `http://` `siteurl` option underneath. We therefore
	 * check the RAW stored option, which is what reflects admin configuration
	 * and what shows up in reset-password emails, webhooks, canonical redirects,
	 * etc. If that is `http://`, a misconfigured proxy that terminated TLS before
	 * reaching PHP could still cause this endpoint to hand the mobile app a cleartext
	 * site URL for the token-exchange POST.
	 *
	 * We deliberately reject (rather than silently normalizing to `https://`)
	 * because:
	 *   1. The misconfig usually affects other things (reset-password emails,
	 *      webhooks, canonical redirects). Failing loudly surfaces it.
	 *   2. Normalizing assumes the site actually serves HTTPS on the same host,
	 *      which we cannot verify from within a single request.
	 *   3. A 500 is strictly safer than a leaky success.
	 *
	 * @return string|\WP_Error The HTTPS site URL, or a WP_Error if it is not HTTPS.
	 */
	private function get_secure_site_url() {
		// Raw option: what the admin actually configured, before `set_url_scheme()`
		// inside `get_site_url()` normalizes it based on the current request's scheme.
		$raw_site_url = get_option( 'siteurl' );
		$raw_scheme   = is_string( $raw_site_url ) ? wp_parse_url( $raw_site_url, PHP_URL_SCHEME ) : null;

		if ( 'https' !== $raw_scheme ) {
			return new \WP_Error(
				'insecure_site_url',
				__( 'QR login cannot be used because the site URL is not configured for HTTPS. Please update the WordPress Address (URL) in Settings → General to use https://.', 'woocommerce' ),
				array( 'status' => 500 )
			);
		}

		// Use get_site_url() for the returned value so any scheme normalization
		// or filtering that WordPress applies downstream is preserved, then
		// validate the final value too. A plugin can still filter `site_url`
		// after the raw option check above; never hand the mobile app an
		// HTTP exchange target.
		$site_url     = get_site_url();
		$final_scheme = wp_parse_url( $site_url, PHP_URL_SCHEME );

		if ( 'https' !== $final_scheme ) {
			return new \WP_Error(
				'insecure_site_url',
				__( 'QR login cannot be used because the site URL is not configured for HTTPS. Please update the WordPress Address (URL) in Settings → General to use https://.', 'woocommerce' ),
				array( 'status' => 500 )
			);
		}

		return $site_url;
	}

	/**
	 * Generate a QR login token.
	 *
	 * Creates a short-lived one-time token that can be exchanged for an Application
	 * Password by the mobile app. The caller is assumed to have already passed the
	 * `manage_woocommerce` capability check in `get_items_permissions_check()`.
	 *
	 * @param \WP_REST_Request<array<string, mixed>> $request Full details about the request.
	 * @return \WP_REST_Response|\WP_Error
	 */
	public function generate_token( $request ) {
		unset( $request );
		// Parameter required by WP REST contract but unused here.

		// Check HTTPS.
		if ( ! is_ssl() ) {
			return new \WP_Error(
				'ssl_required',
				__( 'QR login requires an HTTPS connection.', 'woocommerce' ),
				array( 'status' => 403 )
			);
		}

		// Verify the canonical site URL is HTTPS — is_ssl() alone is not enough
		// when WordPress is behind a misconfigured proxy.
		$site_url = $this->get_secure_site_url();
		if ( is_wp_error( $site_url ) ) {
			return $site_url;
		}

		// Check Application Passwords are available.
		if ( ! $this->are_application_passwords_available() ) {
			return new \WP_Error(
				'application_passwords_unavailable',
				__( 'Application Passwords are not available on this site.', 'woocommerce' ),
				array( 'status' => 501 )
			);
		}

		// Check rate limit.
		if ( ! $this->check_generation_rate_limit( get_current_user_id() ) ) {
			return new \WP_Error(
				'rate_limit_exceeded',
				__( 'Too many QR login requests. Please try again later.', 'woocommerce' ),
				array( 'status' => 429 )
			);
		}

		// Generate a cryptographically secure token.
		$token      = wp_generate_password( 64, false );
		$token_hash = hash( 'sha256', $token );
		$now        = time();
		$expires_at = $now + self::TOKEN_TTL;

		// Structured state-machine record. Subsequent transitions
		// (scan/approve/exchange) gate themselves on the current state at the
		// top of each handler.
		$token_data = array(
			'state'      => self::STATE_PENDING,
			'created_at' => $now,
			'state_at'   => $now,
			'user_id'    => get_current_user_id(),
			'site_url'   => $site_url,
			'expires_at' => $expires_at,
		);

		set_transient( self::TOKEN_TRANSIENT_PREFIX . $token_hash, $token_data, self::TOKEN_TTL );

		// Build the QR URL (deep link for the mobile app).
		$qr_url = sprintf(
			'woocommerce://qr-login?token=%s&siteUrl=%s',
			rawurlencode( $token ),
			rawurlencode( $site_url )
		);

		return rest_ensure_response(
			array(
				'qr_url'     => $qr_url,
				'expires_at' => $expires_at,
				'ttl'        => self::TOKEN_TTL,
			)
		);
	}

	/**
	 * Exchange a QR login token for an Application Password.
	 *
	 * This endpoint does not require authentication — the token serves
	 * as the authentication mechanism.
	 *
	 * @param \WP_REST_Request<array<string, mixed>> $request Full details about the request.
	 * @return \WP_REST_Response|\WP_Error
	 */
	public function exchange_token( $request ) {
		// Refuse to return credentials over a non-HTTPS request.
		if ( ! is_ssl() ) {
			return new \WP_Error(
				'ssl_required',
				__( 'QR login requires an HTTPS connection.', 'woocommerce' ),
				array( 'status' => 403 )
			);
		}

		// Refuse to return credentials bound to a non-HTTPS site URL — see
		// get_secure_site_url() for rationale. A token that was minted while the
		// siteurl was still https:// but has since been changed to http:// should
		// also be refused here.
		$site_url = $this->get_secure_site_url();
		if ( is_wp_error( $site_url ) ) {
			return $site_url;
		}

		// Defensive sanitize even though the REST `sanitize_callback` already
		// did so — guards against future refactors that bypass the callback.
		$token      = sanitize_text_field( (string) $request->get_param( 'token' ) );
		$token_hash = hash( 'sha256', $token );
		$key        = self::TOKEN_TRANSIENT_PREFIX . $token_hash;

		$token_data = get_transient( $key );
		if ( ! is_array( $token_data ) ) {
			if ( ! $this->check_invalid_exchange_rate_limit() ) {
				return new \WP_Error(
					'rate_limit_exceeded',
					__( 'Too many exchange attempts. Please try again later.', 'woocommerce' ),
					array( 'status' => 429 )
				);
			}

			return new \WP_Error(
				'invalid_token',
				__( 'Invalid or expired QR login token.', 'woocommerce' ),
				array( 'status' => 401 )
			);
		}

		// Broad anonymous abuse guard applies only after token lookup. Random
		// invalid requests use the invalid-token bucket above so they cannot
		// exhaust this shared-IP guard for later valid exchanges behind the same
		// proxy/CDN IP.
		if ( ! $this->check_exchange_ip_rate_limit() ) {
			return new \WP_Error(
				'rate_limit_exceeded',
				__( 'Too many exchange attempts. Please try again later.', 'woocommerce' ),
				array( 'status' => 429 )
			);
		}

		if ( ! $this->check_valid_exchange_rate_limit( $token_hash ) ) {
			return new \WP_Error(
				'rate_limit_exceeded',
				__( 'Too many exchange attempts. Please try again later.', 'woocommerce' ),
				array( 'status' => 429 )
			);
		}

		if ( ! $this->claim_token_for_exchange( $token_hash, isset( $token_data['expires_at'] ) ? (int) $token_data['expires_at'] : time() + self::TOKEN_TTL ) ) {
			return new \WP_Error(
				'invalid_token',
				__( 'Invalid or expired QR login token.', 'woocommerce' ),
				array( 'status' => 401 )
			);
		}

		// Re-read after acquiring the database claim in case another process
		// consumed or expired the token while this request was waiting.
		$token_data = get_transient( $key );
		if ( ! is_array( $token_data ) ) {
			$this->release_token_exchange_claim( $token_hash );
			return new \WP_Error(
				'invalid_token',
				__( 'Invalid or expired QR login token.', 'woocommerce' ),
				array( 'status' => 401 )
			);
		}

		// Validate token hasn't expired (belt and suspenders with transient TTL).
		if ( ! empty( $token_data['expires_at'] ) && time() >= (int) $token_data['expires_at'] ) {
			delete_transient( $key );
			$this->delete_session_mapping_for_record( $token_data );
			$this->release_token_exchange_claim( $token_hash );
			return new \WP_Error(
				'token_expired',
				__( 'QR login token has expired.', 'woocommerce' ),
				array( 'status' => 401 )
			);
		}

		// Number-matching enforcement: exchange must be preceded by /scan +
		// /approve. Anything other than `approved` (including `pending` —
		// scan was skipped — and `scanned` — scan completed but merchant
		// didn't tap a number yet) is a hard 412.
		$current_state = isset( $token_data['state'] ) ? (string) $token_data['state'] : self::STATE_PENDING;

		if ( self::STATE_APPROVED !== $current_state ) {
			$this->release_token_exchange_claim( $token_hash );
			return new \WP_Error(
				'qr_login_not_approved',
				__( 'This QR login session has not been approved.', 'woocommerce' ),
				array( 'status' => 412 )
			);
		}

		// Constant-time grant comparison. The grant is bound to this token
		// at /approve time and only handed back to the polling app via
		// /session-status, so an attacker who somehow learned the token
		// can't race the legit app to exchange after approval.
		$submitted_grant = (string) $request->get_param( 'exchange_grant' );
		$stored_grant    = isset( $token_data['exchange_grant'] ) ? (string) $token_data['exchange_grant'] : '';

		if ( '' === $stored_grant || ! hash_equals( $stored_grant, $submitted_grant ) ) {
			$invalid_grant_attempts = isset( $token_data['invalid_grant_attempts'] ) ? (int) $token_data['invalid_grant_attempts'] : 0;
			++$invalid_grant_attempts;

			$token_data['invalid_grant_attempts']          = $invalid_grant_attempts;
			$token_data['invalid_grant_last_attempted_at'] = time();

			if ( $invalid_grant_attempts >= self::MAX_INVALID_GRANT_ATTEMPTS ) {
				$token_data['state']    = self::STATE_REJECTED;
				$token_data['state_at'] = time();

				wc_get_logger()->warning(
					'QR login rejected after repeated invalid exchange grants',
					array(
						'source'  => 'qr-login-security',
						'user_id' => isset( $token_data['user_id'] ) ? (int) $token_data['user_id'] : 0,
						'ip'      => $this->get_client_ip(),
					)
				);
			}

			set_transient( $key, $token_data, $this->get_token_record_ttl( $token_data ) );
			$this->release_token_exchange_claim( $token_hash );
			return new \WP_Error(
				'invalid_exchange_grant',
				__( 'Invalid exchange grant for this QR login session.', 'woocommerce' ),
				array( 'status' => 412 )
			);
		}//end if

		$user_id = $token_data['user_id'];
		$user    = get_userdata( $user_id );

		if ( ! $user ) {
			$this->release_token_exchange_claim( $token_hash );
			return new \WP_Error(
				'user_not_found',
				__( 'User associated with this token no longer exists.', 'woocommerce' ),
				array( 'status' => 404 )
			);
		}

		// Application Passwords may have been disabled after the token was minted.
		if ( ! $this->are_application_passwords_available() ) {
			$this->release_token_exchange_claim( $token_hash );
			return new \WP_Error(
				'application_passwords_unavailable',
				__( 'Application Passwords are not available on this site.', 'woocommerce' ),
				array( 'status' => 501 )
			);
		}

		// Mirror the permission check WP core performs in
		// WP_REST_Application_Passwords_Controller::create_item_permissions_check().
		// Capability or per-user availability filters could have changed in the
		// window between token generation and exchange.
		if ( ! user_can( $user, 'create_app_password', $user_id ) ) {
			$this->release_token_exchange_claim( $token_hash );
			return new \WP_Error(
				'rest_cannot_create_application_passwords',
				__( 'Application passwords are not available for your account. Please contact the site administrator for assistance.', 'woocommerce' ),
				array( 'status' => rest_authorization_required_code() )
			);
		}

		// Source the device payload from the scan record. /qr-login-scan
		// requires a device object, so by the time we reach `approved` it's
		// guaranteed present. Re-sanitize defensively in case the transient
		// was tampered with at the storage layer.
		$device_source = isset( $token_data['challenge']['device'] ) && is_array( $token_data['challenge']['device'] )
			? $token_data['challenge']['device']
			: array();
		$device        = $this->sanitize_device_payload( $device_source );

		// Create an Application Password for the mobile app. The name is
		// descriptive (e.g. "Woo Mobile · iPhone 15 · 2026-04-28") so the user
		// can identify it later in Users → Profile → Application Passwords.
		$app_password_result = \WP_Application_Passwords::create_new_application_password(
			$user_id,
			array(
				'name'   => $this->format_application_password_name( $device ),
				'app_id' => self::APP_ID,
			)
		);

		if ( is_wp_error( $app_password_result ) ) {
			wc_get_logger()->error(
				sprintf(
					'QR login: failed to create Application Password for user %d: %s',
					$user_id,
					$app_password_result->get_error_message()
				),
				array( 'source' => 'mobile-app-qr-login' )
			);
			$this->release_token_exchange_claim( $token_hash );
			return new \WP_Error(
				'application_password_failed',
				__( 'Could not create a mobile-app credential. Please try again, or contact your site administrator.', 'woocommerce' ),
				array( 'status' => 500 )
			);
		}

		list( $new_password, $item ) = $app_password_result;

		// Write a "consumed" record so wc-admin's polling client can transition
		// from "QR shown" to "Signed in successfully on {device}" and surface
		// a revoke button. Same TTL as the original token transient — there's
		// no value in keeping this record longer than the modal that polls it.
		$consumed_record = array(
			'consumed_at' => time(),
			'user_id'     => $user_id,
			'ap_uuid'     => $item['uuid'],
			'ap_name'     => $item['name'],
			'device'      => $device,
		);
		set_transient(
			self::CONSUMED_TRANSIENT_PREFIX . $token_hash,
			$consumed_record,
			self::TOKEN_TTL
		);

		// One-shot: consume only after the Application Password has been
		// successfully created and the consumed record is visible to wc-admin's
		// polling client.
		delete_transient( $key );
		$this->delete_session_mapping_for_record( $token_data );
		$this->release_token_exchange_claim( $token_hash );

		// Notify the merchant out-of-band so they're aware of a fresh sign-in
		// even when they aren't currently looking at wc-admin. Wrapped in a
		// try/catch + filter to keep the exchange path uninterrupted if the
		// site's mailer is misconfigured.
		$this->maybe_send_sign_in_notification_email( $user, $consumed_record );

		return rest_ensure_response(
			array(
				'success'              => true,
				'user_login'           => $user->user_login,
				'user_email'           => $user->user_email,
				'user_id'              => $user_id,
				'site_url'             => $site_url,
				'application_password' => $new_password,
				'uuid'                 => $item['uuid'],
			)
		);
	}

	/**
	 * Get the status of a previously generated QR login token.
	 *
	 * Used by the wc-admin UI to poll while the QR is on screen. Returns one of:
	 *   - `pending`  — token transient exists, has not been exchanged yet.
	 *   - `consumed` — token has been exchanged; payload includes the device that
	 *                  signed in and the AP UUID so the UI can render the
	 *                  confirmation panel and (optionally) revoke the AP.
	 *   - `expired`  — neither transient exists, so the token has expired or
	 *                  was never valid for this user.
	 *
	 * The user calling this endpoint must be the same user who minted the token.
	 * That's defense in depth — tokens are 64 random chars and not realistically
	 * guessable, but cross-user status reads should be impossible regardless.
	 *
	 * @param \WP_REST_Request<array<string, mixed>> $request Full details about the request.
	 * @return \WP_REST_Response|\WP_Error
	 */
	public function get_status( $request ) {
		// Defeat any intermediary cache (Cloudflare, NGINX micro-cache, browser, edge proxy)
		// that might pin this GET to its first response. Polling endpoints are by definition
		// state-bearing — every tick must see the live transient. Returning a stale `scanned`
		// response forever is exactly the symptom we'd see if the cache pins the first hit.
		nocache_headers();

		$user_id = get_current_user_id();

		if ( ! $this->check_status_rate_limit( $user_id ) ) {
			return new \WP_Error(
				'rate_limit_exceeded',
				__( 'Too many QR login status checks. Please try again later.', 'woocommerce' ),
				array( 'status' => 429 )
			);
		}

		$token = (string) $request->get_param( 'token' );
		if ( '' === $token ) {
			return $this->rest_ensure_nocache_response( array( 'status' => 'expired' ) );
		}

		$token_hash = hash( 'sha256', $token );

		// Consumed lookup first — once a token has been exchanged the main
		// transient is deleted, but we keep a one-way breadcrumb at the
		// `_wc_qr_login_consumed_` key so the polling client (which still
		// has the plaintext token) can render the success panel.
		$consumed = get_transient( self::CONSUMED_TRANSIENT_PREFIX . $token_hash );
		if ( is_array( $consumed ) ) {
			if ( ! isset( $consumed['user_id'] ) || (int) $consumed['user_id'] !== (int) $user_id ) {
				return $this->rest_ensure_nocache_response( array( 'status' => 'expired' ) );
			}

			return $this->rest_ensure_nocache_response(
				array(
					'status'      => self::STATE_CONSUMED,
					'consumed_at' => isset( $consumed['consumed_at'] ) ? (int) $consumed['consumed_at'] : null,
					'ap_uuid'     => isset( $consumed['ap_uuid'] ) ? (string) $consumed['ap_uuid'] : null,
					'ap_name'     => isset( $consumed['ap_name'] ) ? (string) $consumed['ap_name'] : null,
					'device'      => isset( $consumed['device'] ) && is_array( $consumed['device'] ) ? $consumed['device'] : array(),
				)
			);
		}

		$record = get_transient( self::TOKEN_TRANSIENT_PREFIX . $token_hash );
		if ( ! is_array( $record ) ) {
			return $this->rest_ensure_nocache_response( array( 'status' => self::STATE_EXPIRED ) );
		}

		// Cross-user defense in depth — same as before.
		if ( ! isset( $record['user_id'] ) || (int) $record['user_id'] !== (int) $user_id ) {
			return $this->rest_ensure_nocache_response( array( 'status' => self::STATE_EXPIRED ) );
		}

		$state = isset( $record['state'] ) ? (string) $record['state'] : self::STATE_PENDING;

		// Rejected / expired states are terminal — surface them directly so
		// wc-admin can render the "Login denied" terminal screen.
		if ( in_array( $state, array( self::STATE_REJECTED, self::STATE_EXPIRED ), true ) ) {
			return $this->rest_ensure_nocache_response( array( 'status' => $state ) );
		}

		if ( ! empty( $record['expires_at'] ) && time() >= (int) $record['expires_at'] ) {
			return $this->rest_ensure_nocache_response( array( 'status' => self::STATE_EXPIRED ) );
		}

		// While in `scanned`, surface the shuffled candidate triple and the
		// device that scanned so wc-admin can render the matching UI. The
		// REAL number is never returned via this endpoint — only the
		// shuffled triple of (real + 2 distractors) is, so an XSS / hostile
		// extension can't read which one is correct from JS state.
		if ( self::STATE_SCANNED === $state ) {
			$challenge = isset( $record['challenge'] ) && is_array( $record['challenge'] ) ? $record['challenge'] : array();
			$numbers   = $this->shuffled_candidate_numbers( $challenge );

			return $this->rest_ensure_nocache_response(
				array(
					'status'     => self::STATE_SCANNED,
					'numbers'    => $numbers,
					'device'     => isset( $challenge['device'] ) && is_array( $challenge['device'] ) ? $challenge['device'] : array(),
					'expires_at' => isset( $challenge['expires_at'] ) ? (int) $challenge['expires_at'] : null,
				)
			);
		}

		// Approved (post-tap, pre-exchange) — surface so a wc-admin tab that
		// reloaded between approve and exchange shows the "Signing in…"
		// transitional state rather than going back to the QR.
		if ( self::STATE_APPROVED === $state ) {
			return $this->rest_ensure_nocache_response( array( 'status' => self::STATE_APPROVED ) );
		}

		// Pending: same shape as before, plus the new `state` field for
		// clients that want to switch on it directly.
		return $this->rest_ensure_nocache_response(
			array(
				'status'     => self::STATE_PENDING,
				'expires_at' => isset( $record['expires_at'] ) ? (int) $record['expires_at'] : null,
			)
		);
	}

	/**
	 * Revoke (delete) the Application Password issued by a QR login exchange.
	 *
	 * The current user must own the AP being revoked — verified via
	 * `WP_Application_Passwords::get_user_application_password()`. We
	 * deliberately do NOT use `current_user_can( 'edit_user', $user_id )`
	 * because that would let a higher-privilege admin revoke another user's AP
	 * here; the QR flow's revoke surface is for "I just authorized this — undo,"
	 * not for site-wide AP management (which lives at Users → Profile).
	 *
	 * @param \WP_REST_Request<array<string, mixed>> $request Full details about the request.
	 * @return \WP_REST_Response|\WP_Error
	 */
	public function revoke_password( $request ) {
		$user_id = get_current_user_id();

		if ( ! $this->check_revoke_rate_limit( $user_id ) ) {
			return new \WP_Error(
				'rate_limit_exceeded',
				__( 'Too many QR login revoke attempts. Please try again later.', 'woocommerce' ),
				array( 'status' => 429 )
			);
		}

		if ( ! $this->are_application_passwords_available() ) {
			return new \WP_Error(
				'application_passwords_unavailable',
				__( 'Application Passwords are not available on this site.', 'woocommerce' ),
				array( 'status' => 501 )
			);
		}

		$uuid = (string) $request->get_param( 'uuid' );

		// Ownership check: the AP must exist AND belong to the current user.
		$ap = \WP_Application_Passwords::get_user_application_password( $user_id, $uuid );
		if ( ! is_array( $ap ) ) {
			return new \WP_Error(
				'application_password_not_found',
				__( 'No matching Application Password to revoke.', 'woocommerce' ),
				array( 'status' => 404 )
			);
		}

		$deleted = \WP_Application_Passwords::delete_application_password( $user_id, $uuid );
		if ( true !== $deleted ) {
			return new \WP_Error(
				'application_password_revoke_failed',
				__( 'Could not revoke the Application Password. Please try again.', 'woocommerce' ),
				array( 'status' => 500 )
			);
		}

		return rest_ensure_response(
			array(
				'success' => true,
				'uuid'    => $uuid,
			)
		);
	}

	/**
	 * Whitelist + sanitize the `device` payload sent by the mobile app.
	 *
	 * Returns an array of strings keyed by the whitelisted keys defined in
	 * `DEVICE_PAYLOAD_KEYS`. Anything outside that whitelist is dropped. Each
	 * value is run through `sanitize_text_field()` and capped at
	 * `DEVICE_FIELD_MAX_LENGTH` characters. The function is total — pass `null`
	 * or anything non-array and you get back `array()`.
	 *
	 * @param mixed $device Raw payload from the request.
	 * @return array<string, string>
	 */
	private function sanitize_device_payload( $device ) {
		if ( ! is_array( $device ) ) {
			return array();
		}

		$sanitized = array();
		foreach ( self::DEVICE_PAYLOAD_KEYS as $key ) {
			if ( ! isset( $device[ $key ] ) || ! is_scalar( $device[ $key ] ) ) {
				continue;
			}
			$value = sanitize_text_field( (string) $device[ $key ] );
			if ( '' === $value ) {
				continue;
			}
			if ( strlen( $value ) > self::DEVICE_FIELD_MAX_LENGTH ) {
				$value = substr( $value, 0, self::DEVICE_FIELD_MAX_LENGTH );
			}
			$sanitized[ $key ] = $value;
		}

		return $sanitized;
	}

	/**
	 * Build a descriptive name for the Application Password issued by the QR
	 * login exchange.
	 *
	 * Preferred: `Woo Mobile · iPhone 15 · 2026-04-28` (model + ISO date).
	 * Falls back to `Woo Mobile · iOS · 2026-04-28` when only the OS is known.
	 * The scan endpoint requires at least model or OS. The legacy fallback is
	 * retained as a defensive guard in case stored token data is corrupted.
	 *
	 * The name is what the merchant sees in WP admin → Users → Profile →
	 * Application Passwords, so it should be human-readable, single-line, and
	 * not contain anything that would only make sense to an engineer.
	 *
	 * @param array<string, string> $device Sanitized device payload.
	 * @return string
	 */
	private function format_application_password_name( array $device ): string {
		$model = isset( $device['model'] ) ? trim( $device['model'] ) : '';
		$os    = isset( $device['os'] ) ? trim( $device['os'] ) : '';

		// Prefer model (e.g. "iPhone 15", "Pixel 10"); fall back to the OS
		// label if a particular device build returns an empty MODEL string.
		// Both fields come from the platform SDK on the mobile side and are
		// effectively always populated, but defending against an empty model
		// is cheaper than chasing the edge case at runtime.
		$descriptor = '' !== $model ? $model : $os;
		if ( '' === $descriptor ) {
			return __( 'WooCommerce Mobile App (QR Login)', 'woocommerce' );
		}

		// Use the site's configured timezone so the date the merchant sees in
		// the AP list matches what they'd see in the rest of wp-admin.
		$date = wp_date( 'Y-m-d' );

		/* translators: 1: device descriptor (model or OS, e.g. "iPhone 15"). 2: ISO date the AP was created. */
		return sprintf( __( 'Woo Mobile · %1$s · %2$s', 'woocommerce' ), $descriptor, $date );
	}

	/**
	 * Per-user rate limit for the polling status endpoint.
	 *
	 * @param int $user_id The user ID.
	 * @return bool True if within rate limit.
	 */
	private function check_status_rate_limit( $user_id ) {
		return QRLoginRateLimits::consume( QRLoginRateLimits::BUCKET_STATUS, (string) $user_id );
	}

	/**
	 * Per-user rate limit for the revoke endpoint.
	 *
	 * @param int $user_id The user ID.
	 * @return bool True if within rate limit.
	 */
	private function check_revoke_rate_limit( $user_id ) {
		return QRLoginRateLimits::consume( QRLoginRateLimits::BUCKET_REVOKE, (string) $user_id );
	}

	/**
	 * Mobile app reports the QR was scanned. Generates the number-match
	 * challenge, marks the state as `scanned`, and returns the *real* number
	 * + a session id back to the app. Public — the token is the auth.
	 *
	 * Hard-break compat: clients that don't send `supports_number_matching`
	 * get 426 Upgrade Required. The Android Task 7 PR adds the flag; older
	 * apps in the wild see a clear "update required" error.
	 *
	 * @param \WP_REST_Request<array<string, mixed>> $request Full details about the request.
	 * @return \WP_REST_Response|\WP_Error
	 */
	public function scan_token( $request ) {
		if ( ! is_ssl() ) {
			return new \WP_Error(
				'ssl_required',
				__( 'QR login requires an HTTPS connection.', 'woocommerce' ),
				array( 'status' => 403 )
			);
		}

		if ( true !== (bool) $request->get_param( 'supports_number_matching' ) ) {
			return new \WP_Error(
				'mobile_app_update_required',
				__( 'This Woo mobile app version is no longer supported for QR sign-in. Please update the app and try again.', 'woocommerce' ),
				array( 'status' => 426 )
			);
		}

		$token      = (string) $request->get_param( 'token' );
		$token_hash = hash( 'sha256', $token );
		$key        = self::TOKEN_TRANSIENT_PREFIX . $token_hash;

		$record = get_transient( $key );
		if ( ! is_array( $record ) ) {
			if ( ! $this->check_invalid_scan_rate_limit() ) {
				return new \WP_Error(
					'rate_limit_exceeded',
					__( 'Too many QR login scans. Please try again later.', 'woocommerce' ),
					array( 'status' => 429 )
				);
			}

			return new \WP_Error(
				'invalid_token',
				__( 'Invalid or expired QR login token.', 'woocommerce' ),
				array( 'status' => 401 )
			);
		}

		if ( ! $this->check_scan_rate_limit() ) {
			return new \WP_Error(
				'rate_limit_exceeded',
				__( 'Too many QR login scans. Please try again later.', 'woocommerce' ),
				array( 'status' => 429 )
			);
		}

		$device = $this->sanitize_device_payload( $request->get_param( 'device' ) );
		if ( empty( $device['model'] ) && empty( $device['os'] ) ) {
			return new \WP_Error(
				'invalid_device',
				__( 'QR login requires device information from the mobile app.', 'woocommerce' ),
				array( 'status' => 400 )
			);
		}

		// Atomic mutex on the read-mutate-write window. Without this, two
		// concurrent scans both pass the state==pending check below and both
		// write a new challenge — last writer wins, the loser's session_id
		// is silently orphaned. The state check is kept as defense-in-depth
		// for any path that bypasses the claim (e.g. the staleness branch).
		if ( ! $this->claim_token_for_scan(
			$token_hash,
			isset( $record['expires_at'] ) ? (int) $record['expires_at'] : time() + self::TOKEN_TTL
		) ) {
			return new \WP_Error(
				'qr_login_already_scanned',
				__( 'This QR login session is no longer accepting scans.', 'woocommerce' ),
				array( 'status' => 409 )
			);
		}

		$current_state = isset( $record['state'] ) ? (string) $record['state'] : self::STATE_PENDING;
		if ( self::STATE_PENDING !== $current_state ) {
			$this->release_token_scan_claim( $token_hash );
			return new \WP_Error(
				'qr_login_already_scanned',
				__( 'This QR login session is no longer accepting scans.', 'woocommerce' ),
				array( 'status' => 409 )
			);
		}

		$challenge_numbers = $this->generate_challenge_numbers();
		$session_id        = wp_generate_uuid4();
		$now               = time();
		$token_expires_at  = isset( $record['expires_at'] ) ? (int) $record['expires_at'] : $now + self::TOKEN_TTL;

		if ( $token_expires_at <= $now ) {
			$this->release_token_scan_claim( $token_hash );
			return new \WP_Error(
				'invalid_token',
				__( 'Invalid or expired QR login token.', 'woocommerce' ),
				array( 'status' => 401 )
			);
		}

		$challenge_expires_at = min( $now + self::CHALLENGE_TTL_SECONDS, $token_expires_at );
		$challenge_ttl        = max( 1, $challenge_expires_at - $now );

		// Shuffle the candidate triple ONCE at scan time and persist the chosen ordering so
		// every subsequent /qr-login-status poll returns the same array. Re-shuffling per-poll
		// would make the wc-admin tile order flicker every 2.5 s — terrible UX, and makes the
		// merchant doubt they're reading the right number.
		$candidates = array_merge( array( $challenge_numbers['real'] ), $challenge_numbers['distractors'] );
		shuffle( $candidates );

		$record['state']     = self::STATE_SCANNED;
		$record['state_at']  = $now;
		$record['challenge'] = array(
			'real'        => $challenge_numbers['real'],
			'distractors' => $challenge_numbers['distractors'],
			'shuffled'    => $candidates,
			'session_id'  => $session_id,
			'expires_at'  => $challenge_expires_at,
			'device'      => $device,
		);

		// Re-use whatever TTL the original transient had left. The challenge
		// window itself is capped to the remaining token lifetime, while the
		// storage TTL keeps the full challenge visible for normal fresh scans.
		$ttl_left    = max( 1, $token_expires_at - $now );
		$storage_ttl = min( $ttl_left, self::CHALLENGE_TTL_SECONDS + 30 );
		set_transient( $key, $record, $storage_ttl );

		// Sibling transient that resolves session_id → token_hash for the
		// app's session-status poll. Stored hashed so the session id isn't
		// directly indexable in wp_options.
		set_transient(
			self::SESSION_TRANSIENT_PREFIX . hash( 'sha256', $session_id ),
			$token_hash,
			$storage_ttl
		);

		$this->release_token_scan_claim( $token_hash );

		return rest_ensure_response(
			array(
				'session_id'  => $session_id,
				'real_number' => $challenge_numbers['real'],
				'expires_in'  => $challenge_ttl,
			)
		);
	}

	/**
	 * Merchant taps a number on wc-admin. Server validates against the
	 * stored real number with `hash_equals()` (constant-time). Correct →
	 * `approved` + mints `exchange_grant`. Wrong → `rejected` (terminal,
	 * security event logged). One-strike: no retry.
	 *
	 * @param \WP_REST_Request<array<string, mixed>> $request Full details about the request.
	 * @return \WP_REST_Response|\WP_Error
	 */
	public function approve_token( $request ) {
		$user_id = get_current_user_id();

		if ( ! $this->check_approve_rate_limit( $user_id ) ) {
			return new \WP_Error(
				'rate_limit_exceeded',
				__( 'Too many QR login approval attempts. Please try again later.', 'woocommerce' ),
				array( 'status' => 429 )
			);
		}

		$token      = (string) $request->get_param( 'token' );
		$token_hash = hash( 'sha256', $token );
		$key        = self::TOKEN_TRANSIENT_PREFIX . $token_hash;

		$record = get_transient( $key );
		if ( ! is_array( $record ) ) {
			return new \WP_Error(
				'invalid_token',
				__( 'Invalid or expired QR login token.', 'woocommerce' ),
				array( 'status' => 401 )
			);
		}

		$approval_claim_expires_at = ! empty( $record['challenge']['expires_at'] )
			? (int) $record['challenge']['expires_at']
			: ( isset( $record['expires_at'] ) ? (int) $record['expires_at'] : time() + self::TOKEN_TTL );
		if ( ! $this->claim_token_for_approval( $token_hash, $approval_claim_expires_at ) ) {
			return new \WP_Error(
				'qr_login_approval_in_progress',
				__( 'This QR login session is already being approved.', 'woocommerce' ),
				array( 'status' => 409 )
			);
		}

		// Re-read after acquiring the database claim in case another request
		// approved, rejected, or expired the challenge while this one was waiting.
		$record = get_transient( $key );
		if ( ! is_array( $record ) ) {
			$this->release_token_approval_claim( $token_hash );
			return new \WP_Error(
				'invalid_token',
				__( 'Invalid or expired QR login token.', 'woocommerce' ),
				array( 'status' => 401 )
			);
		}

		// Same cross-user defense as get_status — only the user that minted
		// the token can approve it.
		if ( ! isset( $record['user_id'] ) || (int) $record['user_id'] !== (int) $user_id ) {
			$this->release_token_approval_claim( $token_hash );
			return new \WP_Error(
				'invalid_token',
				__( 'Invalid or expired QR login token.', 'woocommerce' ),
				array( 'status' => 401 )
			);
		}

		if ( ! empty( $record['expires_at'] ) && time() >= (int) $record['expires_at'] ) {
			$record['state']    = self::STATE_EXPIRED;
			$record['state_at'] = time();
			set_transient( $key, $record, 60 );
			$this->release_token_approval_claim( $token_hash );
			return new \WP_Error(
				'qr_login_expired',
				__( 'The QR login challenge has expired. Please generate a new code.', 'woocommerce' ),
				array( 'status' => 410 )
			);
		}

		$current_state = isset( $record['state'] ) ? (string) $record['state'] : self::STATE_PENDING;
		if ( self::STATE_SCANNED !== $current_state ) {
			$this->release_token_approval_claim( $token_hash );
			return new \WP_Error(
				'qr_login_not_scanned',
				__( 'This QR login session is not waiting for approval.', 'woocommerce' ),
				array( 'status' => 409 )
			);
		}

		// Challenge expiry — normally 90 s after scan, capped by token expiry.
		if ( ! empty( $record['challenge']['expires_at'] ) && time() > (int) $record['challenge']['expires_at'] ) {
			$record['state']    = self::STATE_EXPIRED;
			$record['state_at'] = time();
			set_transient( $key, $record, 60 );
			$this->release_token_approval_claim( $token_hash );
			return new \WP_Error(
				'qr_login_expired',
				__( 'The QR login challenge has expired. Please generate a new code.', 'woocommerce' ),
				array( 'status' => 410 )
			);
		}

		$choice = (string) $request->get_param( 'choice' );
		$real   = isset( $record['challenge']['real'] ) ? (string) $record['challenge']['real'] : '';

		// Constant-time compare. Defends against PHP string-comparison fast
		// paths that can leak prefix-matching info via timing.
		if ( '' === $real || ! hash_equals( $real, $choice ) ) {
			$record['state']    = self::STATE_REJECTED;
			$record['state_at'] = time();
			set_transient( $key, $record, 60 );

			wc_get_logger()->warning(
				'QR login number-match rejected — wrong choice submitted',
				array(
					'source'  => 'qr-login-security',
					'user_id' => (int) $user_id,
					'ip'      => $this->get_client_ip(),
					'device'  => isset( $record['challenge']['device'] ) ? $record['challenge']['device'] : array(),
				)
			);

			$this->release_token_approval_claim( $token_hash );
			return rest_ensure_response( array( 'state' => self::STATE_REJECTED ) );
		}

		$record['state']          = self::STATE_APPROVED;
		$record['state_at']       = time();
		$record['exchange_grant'] = bin2hex( random_bytes( self::EXCHANGE_GRANT_BYTES ) );
		$ttl                      = max(
			1,
			isset( $record['expires_at'] ) ? (int) $record['expires_at'] - time() : self::CHALLENGE_TTL_SECONDS
		);

		set_transient( $key, $record, $ttl );
		$this->release_token_approval_claim( $token_hash );

		return rest_ensure_response( array( 'state' => self::STATE_APPROVED ) );
	}

	/**
	 * Mobile app polls this with the session id from /scan. Returns the
	 * current state of the underlying token, plus — when state is
	 * `approved` — the `exchange_grant` nonce required by /qr-login-exchange.
	 *
	 * @param \WP_REST_Request<array<string, mixed>> $request Full details about the request.
	 * @return \WP_REST_Response|\WP_Error
	 */
	public function get_session_status( $request ) {
		// Defeat any intermediary cache (Cloudflare, NGINX micro-cache, OkHttp's shared
		// cache, edge proxy) that might pin this GET to its first response. Polling
		// endpoints are by definition state-bearing — every tick must see the live
		// transient. Returning a stale `scanned` response forever is exactly the
		// symptom we see if the cache pins the first hit.
		nocache_headers();

		if ( ! is_ssl() ) {
			return new \WP_Error(
				'ssl_required',
				__( 'QR login requires an HTTPS connection.', 'woocommerce' ),
				array( 'status' => 403 )
			);
		}

		$session_id     = (string) $request->get_param( 'session_id' );
		$submitted_hash = (string) $request->get_param( 'token_hash' );

		$token_hash = get_transient( self::SESSION_TRANSIENT_PREFIX . hash( 'sha256', $session_id ) );
		if ( ! is_string( $token_hash ) || '' === $token_hash ) {
			// Either the session never existed or it has expired. Either way,
			// surface as expired to the polling app.
			return $this->rest_ensure_nocache_response( array( 'state' => self::STATE_EXPIRED ) );
		}

		// Bind grant delivery to proof of token knowledge: an attacker who
		// learns the session_id alone (mobile logs, network capture, debug
		// output) cannot poll for state transitions and walk away with the
		// `exchange_grant` the moment the merchant approves. The mobile app
		// already holds the plaintext token from the QR scan — passing
		// SHA-256(token) on every poll is essentially free for it.
		// `hash_equals` for constant-time comparison; `expired` opacity so
		// we never leak whether the session_id is real or not.
		if ( ! hash_equals( $token_hash, $submitted_hash ) ) {
			return $this->rest_ensure_nocache_response( array( 'state' => self::STATE_EXPIRED ) );
		}

		if ( ! $this->check_session_status_rate_limit( $session_id ) ) {
			return new \WP_Error(
				'rate_limit_exceeded',
				__( 'Too many QR login session-status checks. Please try again later.', 'woocommerce' ),
				array( 'status' => 429 )
			);
		}

		$record = get_transient( self::TOKEN_TRANSIENT_PREFIX . $token_hash );
		if ( ! is_array( $record ) ) {
			return $this->rest_ensure_nocache_response( array( 'state' => self::STATE_EXPIRED ) );
		}

		$state    = isset( $record['state'] ) ? (string) $record['state'] : self::STATE_PENDING;
		$response = array( 'state' => $state );

		if ( in_array( $state, array( self::STATE_REJECTED, self::STATE_EXPIRED ), true ) ) {
			return $this->rest_ensure_nocache_response( $response );
		}

		if ( ! empty( $record['expires_at'] ) && time() >= (int) $record['expires_at'] ) {
			return $this->rest_ensure_nocache_response( array( 'state' => self::STATE_EXPIRED ) );
		}

		if ( self::STATE_APPROVED === $state && ! empty( $record['exchange_grant'] ) ) {
			$response['exchange_grant'] = (string) $record['exchange_grant'];
		}

		return $this->rest_ensure_nocache_response( $response );
	}

	/**
	 * Generate a 1-real + 2-distractor number triple for the match
	 * challenge. Distractors must differ from the real number and from each
	 * other by ≥ 100 — defends against a partial-read leak fingerprinting
	 * the real one (no `042` vs `043` near-misses).
	 *
	 * Uses `random_int()` (CSPRNG-backed) rather than `wp_rand()`, which can
	 * fall back to mt_rand() and is predictable.
	 *
	 * @return array{real: string, distractors: array<int, string>}
	 * @throws \RuntimeException If a valid distractor set cannot be generated.
	 */
	private function generate_challenge_numbers(): array {
		$real             = random_int( 0, 999 );
		$valid_candidates = array();

		for ( $candidate = 0; $candidate <= 999; $candidate++ ) {
			if ( $candidate !== $real && abs( $candidate - $real ) >= 100 ) {
				$valid_candidates[] = $candidate;
			}
		}

		if ( empty( $valid_candidates ) ) {
			throw new \RuntimeException( 'QR login challenge generator could not find a valid first distractor.' );
		}

		$first_index = random_int( 0, count( $valid_candidates ) - 1 );
		$first       = $valid_candidates[ $first_index ];

		$second_candidates = array_values(
			array_filter(
				$valid_candidates,
				static function ( $candidate ) use ( $first ) {
					return abs( $candidate - $first ) >= 100;
				}
			)
		);

		if ( empty( $second_candidates ) ) {
			throw new \RuntimeException( 'QR login challenge generator could not find a valid second distractor.' );
		}

		$second      = $second_candidates[ random_int( 0, count( $second_candidates ) - 1 ) ];
		$distractors = array( $first, $second );

		return array(
			'real'        => str_pad( (string) $real, 3, '0', STR_PAD_LEFT ),
			'distractors' => array_map(
				static function ( $n ) {
					return str_pad( (string) $n, 3, '0', STR_PAD_LEFT );
				},
				$distractors
			),
		);
	}

	/**
	 * Build the shuffled candidate triple returned by `/qr-login-status`
	 * while in the `scanned` state. The order is fixed at scan time (in
	 * `scan_token`) and stored in `challenge.shuffled` so every poll returns
	 * the same array — re-shuffling per-poll caused visible tile flicker on
	 * wc-admin. Falls back to building + shuffling on the fly for any token
	 * record that predates the persisted-shuffle change.
	 *
	 * @param array<string, mixed> $challenge The challenge payload from the token record.
	 * @return array<int, string>
	 */
	private function shuffled_candidate_numbers( array $challenge ): array {
		if ( isset( $challenge['shuffled'] ) && is_array( $challenge['shuffled'] ) ) {
			return array_map( 'strval', $challenge['shuffled'] );
		}

		$real        = isset( $challenge['real'] ) ? (string) $challenge['real'] : '';
		$distractors = isset( $challenge['distractors'] ) && is_array( $challenge['distractors'] )
			? array_map( 'strval', $challenge['distractors'] )
			: array();

		$candidates = array_merge( array( $real ), $distractors );
		shuffle( $candidates );
		return $candidates;
	}

	/**
	 * Per-IP rate limit on /qr-login-scan.
	 *
	 * @return bool True if within rate limit.
	 */
	private function check_scan_rate_limit() {
		return QRLoginRateLimits::consume( QRLoginRateLimits::BUCKET_SCAN, $this->get_client_ip() );
	}

	/**
	 * Per-user rate limit on /qr-login-approve.
	 *
	 * @param int $user_id The user ID.
	 * @return bool True if within rate limit.
	 */
	private function check_approve_rate_limit( $user_id ) {
		return QRLoginRateLimits::consume( QRLoginRateLimits::BUCKET_APPROVE, (string) $user_id );
	}

	/**
	 * Per-session rate limit on /qr-login-session-status.
	 *
	 * @param string $session_id The session ID.
	 * @return bool True if within rate limit.
	 */
	private function check_session_status_rate_limit( $session_id ) {
		return QRLoginRateLimits::consume( QRLoginRateLimits::BUCKET_SESSION_STATUS, $session_id );
	}

	/**
	 * Send the merchant a transactional email summarizing a successful QR
	 * sign-in, unless they (or a site owner) opt out via the
	 * `woocommerce_qr_login_should_send_signin_email` filter.
	 *
	 * Wrapped so a misconfigured mailer cannot break the exchange path. Mailer
	 * false returns and exceptions are logged, but delivery never blocks the API
	 * response.
	 *
	 * @param \WP_User             $user            The user who minted the token (recipient).
	 * @param array<string, mixed> $consumed_record The record persisted to the consumed transient (keys: consumed_at, user_id, ap_uuid, ap_name, device).
	 * @return void
	 */
	private function maybe_send_sign_in_notification_email( \WP_User $user, array $consumed_record ): void {
		/**
		 * Filter whether to send the QR sign-in notification email.
		 *
		 * Default: true. Return false to suppress the send for a specific
		 * user, environment (e.g. staging), or test run.
		 *
		 * @since 10.9.0
		 *
		 * @param bool                 $should_send     Whether to send the email.
		 * @param \WP_User             $user            The user who minted the QR token.
		 * @param array<string, mixed> $consumed_record The consumed record about to be emailed (keys: consumed_at, user_id, ap_uuid, ap_name, device).
		 */
		$should_send = (bool) apply_filters(
			'woocommerce_qr_login_should_send_signin_email',
			true,
			$user,
			$consumed_record
		);

		if ( ! $should_send ) {
			return;
		}

		try {
			if ( ! $this->send_sign_in_notification_email( $user, $consumed_record ) ) {
				wc_get_logger()->warning(
					sprintf(
						'QR sign-in notification email failed for user %d: wp_mail returned false',
						$user->ID
					),
					array( 'source' => 'mobile-app-qr-login' )
				);
			}
		} catch ( \Throwable $e ) {
			// Don't surface mailer failures to the exchange response — the
			// merchant already has the API result, and the email is best-effort.
			// Log instead so a misconfigured mailer is observable rather than
			// invisible. Catch \Throwable so an \Error from the mailer also
			// stays out of the exchange path.
			wc_get_logger()->warning(
				sprintf(
					'QR sign-in notification email failed for user %d: %s',
					$user->ID,
					$e->getMessage()
				),
				array( 'source' => 'mobile-app-qr-login' )
			);
		}
	}

	/**
	 * Render and dispatch the sign-in notification email.
	 *
	 * Uses `wp_mail()` directly with our own minimal HTML shell rather than
	 * `WC()->mailer()->wrap_message()` — the WC wrapper auto-prepends a small
	 * site-name header that duplicates the subject line shown by most clients
	 * and constrains the body width. Owning the wrapper lets us deliver one
	 * coherent layout.
	 *
	 * @param \WP_User             $user            Recipient.
	 * @param array<string, mixed> $consumed_record The consumed record (same shape as `maybe_send_sign_in_notification_email`).
	 * @return bool True if WordPress accepted the message for delivery.
	 */
	private function send_sign_in_notification_email( \WP_User $user, array $consumed_record ): bool {
		$site_name = wp_specialchars_decode(
			(string) get_bloginfo( 'name' ),
			ENT_QUOTES
		);

		/* translators: %s: site name. */
		$subject = sprintf( __( 'A new device signed in to %s', 'woocommerce' ), $site_name );

		$body_html = $this->render_sign_in_notification_email_body( $user, $consumed_record, $site_name, $subject );

		return wp_mail(
			$user->user_email,
			$subject,
			$body_html,
			array( 'Content-Type: text/html; charset=UTF-8' )
		);
	}

	/**
	 * Render the full HTML email document for the sign-in notification.
	 *
	 * @param \WP_User             $user            Recipient.
	 * @param array<string, mixed> $consumed_record The consumed record (same shape as `maybe_send_sign_in_notification_email`).
	 * @param string               $site_name       Decoded site name (passed in to avoid double-decoding).
	 * @param string               $subject         Email subject; rendered as the in-body heading.
	 * @return string Rendered HTML document.
	 */
	private function render_sign_in_notification_email_body( \WP_User $user, array $consumed_record, string $site_name, string $subject ): string {
		$device           = $consumed_record['device'] ?? array();
		$consumed_at      = isset( $consumed_record['consumed_at'] ) ? (int) $consumed_record['consumed_at'] : time();
		$ap_name          = $consumed_record['ap_name'] ?? '';
		$applications_url = admin_url( 'profile.php#application-passwords-section' );

		ob_start();
		include __DIR__ . '/views/mobile-app-qr-login-signin-email.php';
		$html = ob_get_clean();

		return is_string( $html ) ? $html : '';
	}
}

Filemanager

Name Type Size Permission Actions
AI Folder 0755
RateLimits Folder 0755
Reports Folder 0755
Templates Folder 0755
views Folder 0755
AnalyticsImports.php File 9.09 KB 0644
Coupons.php File 2.15 KB 0644
CustomAttributeTraits.php File 3.4 KB 0644
Customers.php File 2.11 KB 0644
Data.php File 939 B 0644
DataCountries.php File 1.12 KB 0644
DataDownloadIPs.php File 4.15 KB 0644
Experiments.php File 1.82 KB 0644
Features.php File 1.7 KB 0644
Init.php File 10.58 KB 0644
LaunchYourStore.php File 5.15 KB 0644
Leaderboards.php File 18.22 KB 0644
Marketing.php File 4.9 KB 0644
MarketingCampaignTypes.php File 6.02 KB 0644
MarketingCampaigns.php File 9.64 KB 0644
MarketingChannels.php File 5.74 KB 0644
MarketingOverview.php File 3.41 KB 0644
MarketingRecommendations.php File 5.94 KB 0644
MobileAppMagicLink.php File 2.1 KB 0644
MobileAppQRLogin.php File 74.51 KB 0644
NoteActions.php File 3.47 KB 0644
Notes.php File 25.49 KB 0644
Notice.php File 2.38 KB 0644
OnboardingFreeExtensions.php File 2.58 KB 0644
OnboardingPlugins.php File 10.79 KB 0644
OnboardingProductTypes.php File 1.8 KB 0644
OnboardingProducts.php File 1.94 KB 0644
OnboardingProfile.php File 18.38 KB 0644
OnboardingTasks.php File 32.02 KB 0644
OnboardingThemes.php File 5.49 KB 0644
Options.php File 9.96 KB 0644
Orders.php File 10.13 KB 0644
PaymentGatewaySuggestions.php File 5.91 KB 0644
Plugins.php File 21.27 KB 0644
ProductAttributeTerms.php File 4.36 KB 0644
ProductAttributes.php File 4.46 KB 0644
ProductCategories.php File 458 B 0644
ProductForm.php File 3.06 KB 0644
ProductReviews.php File 1.3 KB 0644
ProductVariations.php File 6.03 KB 0644
Products.php File 9.73 KB 0644
ProductsLowInStock.php File 17.62 KB 0644
SettingOptions.php File 878 B 0644
Settings.php File 688 B 0644
ShippingPartnerSuggestions.php File 6.33 KB 0644
Taxes.php File 4.9 KB 0644
Themes.php File 6.12 KB 0644