| @@ -15,12 +15,8 @@ | ||
| 15 | 15 | use WCPOS\WooCommercePOS\Services\Auth as AuthService; |
| 16 | 16 | use WCPOS\WooCommercePOS\Services\Extensions; |
| 17 | 17 | use WCPOS\WooCommercePOS\Services\Receipt_Snapshot_Store; |
| 18 | 18 | use WCPOS\WooCommercePOS\Services\Settings as SettingsService; |
| 19 | -use WP_HTTP_Response; | |
| 20 | -use WP_REST_Request; | |
| 21 | -use WP_REST_Server; | |
| 22 | -use const DOING_AJAX; | |
| 23 | 19 | |
| 24 | 20 | /** |
| 25 | 21 | * Init class. |
| 26 | 22 | */ |
| @@ -25,41 +21,307 @@ | ||
| 25 | 21 | * Init class. |
| 26 | 22 | */ |
| 27 | 23 | class Init { |
| 28 | 24 | /** |
| 29 | - * Constructor. | |
| 25 | + * Observer awaiting the constructor's non-hook seed step. | |
| 26 | + * | |
| 27 | + * @var Sync\Visibility_Observer|null | |
| 30 | 28 | */ |
| 29 | + private $visibility_observer; | |
| 30 | + | |
| 31 | + /** | |
| 32 | + * Install the ordered wiring declared by {@see hook_rows()}. | |
| 33 | + * | |
| 34 | + * Non-hook setup stays here, interleaved at its original registration boundaries. | |
| 35 | + */ | |
| 31 | 36 | public function __construct() { |
| 32 | - // global helper functions. | |
| 33 | 37 | require_once PLUGIN_PATH . 'includes/wcpos-functions.php'; |
| 34 | 38 | require_once PLUGIN_PATH . 'includes/wcpos-store-functions.php'; |
| 39 | + wp_cache_add_global_groups( 'wc_pos_user_uuid_locks' ); | |
| 35 | 40 | |
| 36 | - // Tracking consent pop-up + callout. Registered here (during | |
| 37 | - // plugins_loaded) so its lifecycle hooks (activated_plugin, | |
| 38 | - // upgrader_process_complete) are in place before those actions | |
| 39 | - // fire on a plugin activation or update request. | |
| 40 | - new Consent(); | |
| 41 | + $rows = $this->hook_rows( true ); | |
| 42 | + Hook_Manifest::validate( $rows ); | |
| 43 | + Hook_Manifest::install( wp_list_filter( $rows, array( 'phase' => 'pre-latch' ) ) ); | |
| 44 | + $sync_latched = Sync\Api::SCHEMA_VERSION === get_option( Sync\Api::SCHEMA_OPTION, null ); | |
| 45 | + foreach ( $rows as $row ) { | |
| 46 | + if ( 'pre-latch' === $row['phase'] || ( 'sync-latched' === $row['phase'] && ! $sync_latched ) ) { | |
| 47 | + continue; | |
| 48 | + } | |
| 49 | + if ( array( $this, 'init' ) === $row['callback'] ) { | |
| 50 | + ( new Sync\Config_Fingerprint() )->maybe_cleanup_legacy_options(); | |
| 51 | + } | |
| 52 | + Hook_Manifest::install( array( $row ) ); | |
| 53 | + if ( null !== $this->visibility_observer ) { | |
| 54 | + $this->visibility_observer->maybe_seed_hidden_tombstones(); | |
| 55 | + $this->visibility_observer = null; | |
| 56 | + } | |
| 57 | + } | |
| 58 | + } | |
| 41 | 59 | |
| 42 | - // Init hooks. | |
| 43 | - add_action( 'init', array( $this, 'init' ) ); | |
| 44 | - add_action( 'rest_api_init', array( $this, 'init_rest_api' ), 20 ); | |
| 45 | - add_filter( 'query_vars', array( $this, 'query_vars' ) ); | |
| 60 | + /** | |
| 61 | + * Declare bootstrap wiring in registration order, without installing it. | |
| 62 | + * | |
| 63 | + * Null hooks invoke registrars immediately; their internal priorities/arity stay | |
| 64 | + * in register_hooks(). Phases keep the schema read after pre-latch hooks; | |
| 65 | + * sync-latched rows are post-read hooks omitted when the latch is down. | |
| 66 | + * The guard registrar MUST precede the JWT row: both register at priority 20, | |
| 67 | + * after core cookie/application-password handlers. Reversing them attributes | |
| 68 | + * JWT identity to prior authentication and fails open on /wc/v3/orders. | |
| 69 | + * | |
| 70 | + * @param bool $sync_latched Whether the verified sync schema latch is set. | |
| 71 | + * @return array Ordered rows consumed by Hook_Manifest::install(). | |
| 72 | + */ | |
| 73 | + public function hook_rows( bool $sync_latched ): array { | |
| 74 | + $rows = array( | |
| 75 | + array( | |
| 76 | + 'hook' => null, | |
| 77 | + 'callback' => static function (): void { | |
| 78 | + new Consent(); | |
| 79 | + }, | |
| 80 | + 'priority' => 10, | |
| 81 | + 'args' => 0, | |
| 82 | + 'reason' => 'Default 10; lifecycle hooks must exist during plugins_loaded, before activation/update actions.', | |
| 83 | + 'phase' => 'pre-latch', | |
| 84 | + ), | |
| 85 | + array( | |
| 86 | + 'hook' => 'woocommerce_pos_rest_api_controllers', | |
| 87 | + 'callback' => array( Sync\Api::class, 'register_controllers' ), | |
| 88 | + 'priority' => 10, | |
| 89 | + 'args' => 1, | |
| 90 | + 'reason' => 'Default; sole callback. Response registrars stay inside this filter to retain REST activation timing.', | |
| 91 | + 'phase' => 'pre-latch', | |
| 92 | + ), | |
| 93 | + array( | |
| 94 | + 'hook' => Sync\Integrity_Digest::REBUILD_HOOK, | |
| 95 | + 'callback' => array( Sync\Integrity_Digest::class, 'run_scheduled_rebuild' ), | |
| 96 | + 'priority' => 10, | |
| 97 | + 'args' => 1, | |
| 98 | + 'reason' => 'Default; sole callback. Unlatched so an already-scheduled rebuild still has a listener.', | |
| 99 | + 'phase' => 'pre-latch', | |
| 100 | + ), | |
| 101 | + array( | |
| 102 | + 'hook' => null, | |
| 103 | + 'callback' => array( Sync\Meta_Normalizer::class, 'register_hooks' ), | |
| 104 | + 'priority' => 10, | |
| 105 | + 'args' => 0, | |
| 106 | + 'reason' => 'Priority 5 before revision 9 and augmentation 10, so revisions match bare wc/v3 rereads; also serves orders.', | |
| 107 | + 'phase' => 'sync-latched', | |
| 108 | + ), | |
| 109 | + array( | |
| 110 | + 'hook' => 'woocommerce_pos_sync_serialized_order', | |
| 111 | + 'callback' => array( Sync\Pos_Uuid::class, 'stamp_serialized_record' ), | |
| 112 | + 'priority' => 10, | |
| 113 | + 'args' => 3, | |
| 114 | + 'reason' => 'After normalization at 5, in step with product stampers at 10.', | |
| 115 | + 'phase' => 'sync-latched', | |
| 116 | + ), | |
| 117 | + array( | |
| 118 | + 'hook' => null, | |
| 119 | + 'callback' => array( Sync\Augmentation_Pipeline::class, 'install' ), | |
| 120 | + 'priority' => 10, | |
| 121 | + 'args' => 0, | |
| 122 | + 'reason' => 'Revision 9 hashes normalized, unaugmented bytes; UUID/digest/projections at 10 preserve extension order, including order-pull digests.', | |
| 123 | + 'phase' => 'sync-latched', | |
| 124 | + ), | |
| 125 | + array( | |
| 126 | + 'hook' => null, | |
| 127 | + 'callback' => array( Sync\Pos_Uuid::class, 'register_hooks' ), | |
| 128 | + 'priority' => 10, | |
| 129 | + 'args' => 0, | |
| 130 | + 'reason' => 'Default 10; identity is unconditional: before-save UUIDs land in the same write and native restores re-prove ownership (ADR 0038).', | |
| 131 | + 'phase' => 'post-latch', | |
| 132 | + ), | |
| 133 | + array( | |
| 134 | + 'hook' => null, | |
| 135 | + 'callback' => static function (): void { | |
| 136 | + ( new Sync\Sync_Journal() )->register_hooks(); | |
| 137 | + }, | |
| 138 | + 'priority' => 10, | |
| 139 | + 'args' => 0, | |
| 140 | + 'reason' => 'Default 10; dirty order updates coalesce until shutdown at PHP_INT_MAX, after WooCommerce customer 10/session 20 saves.', | |
| 141 | + 'phase' => 'sync-latched', | |
| 142 | + ), | |
| 143 | + array( | |
| 144 | + 'hook' => null, | |
| 145 | + 'callback' => function (): void { | |
| 146 | + $this->visibility_observer = new Sync\Visibility_Observer(); | |
| 147 | + $this->visibility_observer->register_hooks(); | |
| 148 | + }, | |
| 149 | + 'priority' => 10, | |
| 150 | + 'args' => 0, | |
| 151 | + 'reason' => 'Default 10 after journal; records servable-set transitions, using generic pre-delete_option; Init then seeds tombstones.', | |
| 152 | + 'phase' => 'sync-latched', | |
| 153 | + ), | |
| 154 | + array( | |
| 155 | + 'hook' => null, | |
| 156 | + 'callback' => static function (): void { | |
| 157 | + ( new Sync\Sync_Journal_Purge() )->register_hooks(); | |
| 158 | + }, | |
| 159 | + 'priority' => 10, | |
| 160 | + 'args' => 0, | |
| 161 | + 'reason' => 'Default 10; sole cron listener; the registrar also schedules the daily purge.', | |
| 162 | + 'phase' => 'sync-latched', | |
| 163 | + ), | |
| 164 | + array( | |
| 165 | + 'hook' => null, | |
| 166 | + 'callback' => static function (): void { | |
| 167 | + ( new Sync\Integrity_Digest() )->register_hooks(); | |
| 168 | + }, | |
| 169 | + 'priority' => 10, | |
| 170 | + 'args' => 0, | |
| 171 | + 'reason' => 'Default 10, shutdown PHP_INT_MAX; journal registers first on shared hooks (reason unknown); dirty digests coalesce until flush.', | |
| 172 | + 'phase' => 'sync-latched', | |
| 173 | + ), | |
| 174 | + array( | |
| 175 | + 'hook' => 'init', | |
| 176 | + 'callback' => array( $this, 'init' ), | |
| 177 | + 'priority' => 10, | |
| 178 | + 'args' => 1, | |
| 179 | + 'reason' => 'Default 10; free services must exist before Pro init at 20.', | |
| 180 | + 'phase' => 'post-latch', | |
| 181 | + ), | |
| 182 | + array( | |
| 183 | + 'hook' => 'rest_api_init', | |
| 184 | + 'callback' => array( $this, 'init_rest_api' ), | |
| 185 | + 'priority' => 20, | |
| 186 | + 'args' => 1, | |
| 187 | + 'reason' => 'Original reason unknown (8f2b9eac); Pro deliberately registers before free at 9.', | |
| 188 | + 'phase' => 'post-latch', | |
| 189 | + ), | |
| 190 | + array( | |
| 191 | + 'hook' => 'query_vars', | |
| 192 | + 'callback' => array( $this, 'query_vars' ), | |
| 193 | + 'priority' => 10, | |
| 194 | + 'args' => 1, | |
| 195 | + 'reason' => 'Default; appends one variable.', | |
| 196 | + 'phase' => 'post-latch', | |
| 197 | + ), | |
| 198 | + array( | |
| 199 | + 'hook' => 'pre_update_option_woocommerce_pos_pro_settings_license', | |
| 200 | + 'callback' => array( self::class, 'remove_license_transient' ), | |
| 201 | + 'priority' => 10, | |
| 202 | + 'args' => 2, | |
| 203 | + 'reason' => 'Default; the reentrancy guard, not priority, makes legacy Pro license cache invalidation safe (f33b8d655).', | |
| 204 | + 'phase' => 'post-latch', | |
| 205 | + ), | |
| 206 | + array( | |
| 207 | + 'hook' => null, | |
| 208 | + 'callback' => array( Rest_Cors::class, 'register_hooks' ), | |
| 209 | + 'priority' => 10, | |
| 210 | + 'args' => 0, | |
| 211 | + 'reason' => 'Unconditional for unmarked preflights/relay; serve at 20 after core CORS at 10 so WCPOS is the last writer.', | |
| 212 | + 'phase' => 'post-latch', | |
| 213 | + ), | |
| 214 | + array( | |
| 215 | + 'hook' => 'send_headers', | |
| 216 | + 'callback' => array( $this, 'send_headers' ), | |
| 217 | + 'priority' => 99, | |
| 218 | + 'args' => 1, | |
| 219 | + 'reason' => 'Unknown beyond running late for WPSEO integration (62da70551).', | |
| 220 | + 'phase' => 'post-latch', | |
| 221 | + ), | |
| 222 | + array( | |
| 223 | + 'hook' => 'send_headers', | |
| 224 | + 'callback' => array( $this, 'remove_x_frame_options' ), | |
| 225 | + 'priority' => 9999, | |
| 226 | + 'args' => 1, | |
| 227 | + 'reason' => 'Must remove X-Frame-Options AFTER security plugins set it (80ee545a5).', | |
| 228 | + 'phase' => 'post-latch', | |
| 229 | + ), | |
| 230 | + array( | |
| 231 | + 'hook' => null, | |
| 232 | + 'callback' => static function (): void { | |
| 233 | + ( new Services\Core_Order_Audit_Guard() )->register_hooks(); | |
| 234 | + }, | |
| 235 | + 'priority' => 10, | |
| 236 | + 'args' => 0, | |
| 237 | + 'reason' => 'Auth provenance at 20 MUST register before JWT at 20, after core cookie/password auth; rest_pre_dispatch reads it at 10.', | |
| 238 | + 'phase' => 'post-latch', | |
| 239 | + ), | |
| 240 | + array( | |
| 241 | + 'hook' => null, | |
| 242 | + 'callback' => array( Sync\Coupon_Modified_Date::class, 'register_hooks' ), | |
| 243 | + 'priority' => 10, | |
| 244 | + 'args' => 0, | |
| 245 | + 'reason' => 'Default 10; unconditional meta-only coupon edit timestamps for date-based replication; journal uses wall clock, not post_modified.', | |
| 246 | + 'phase' => 'post-latch', | |
| 247 | + ), | |
| 248 | + array( | |
| 249 | + 'hook' => 'determine_current_user', | |
| 250 | + 'callback' => array( $this, 'determine_current_user_early' ), | |
| 251 | + 'priority' => 20, | |
| 252 | + 'args' => 1, | |
| 253 | + 'reason' => 'At 20 AFTER the audit guard and core cookie/password handlers; register before init, regardless of the request marker.', | |
| 254 | + 'phase' => 'post-latch', | |
| 255 | + ), | |
| 256 | + array( | |
| 257 | + 'hook' => null, | |
| 258 | + 'callback' => static function (): void { | |
| 259 | + ( new Services\Lifecycle_Events() )->register_hooks(); | |
| 260 | + }, | |
| 261 | + 'priority' => 10, | |
| 262 | + 'args' => 0, | |
| 263 | + 'reason' => 'Default 10; append after auth pair; admin_init flushes pending events and consent-gates refresh scheduling, not the cron listener.', | |
| 264 | + 'phase' => 'post-latch', | |
| 265 | + ), | |
| 266 | + array( | |
| 267 | + 'hook' => null, | |
| 268 | + 'callback' => static function (): void { | |
| 269 | + Services\Error_Reporter::instance()->register_hooks(); | |
| 270 | + }, | |
| 271 | + 'priority' => 10, | |
| 272 | + 'args' => 0, | |
| 273 | + 'reason' => 'Append after lifecycle; REST priority 999 reports the final response status, gated by consent (#1811).', | |
| 274 | + 'phase' => 'post-latch', | |
| 275 | + ), | |
| 276 | + ); | |
| 46 | 277 | |
| 47 | - // Headers for API discoverability. | |
| 48 | - add_filter( 'rest_pre_serve_request', array( $this, 'rest_pre_serve_request' ), 5, 4 ); | |
| 49 | - add_action( 'send_headers', array( $this, 'send_headers' ), 99, 1 ); | |
| 50 | - add_action( 'send_headers', array( $this, 'remove_x_frame_options' ), 9999, 1 ); | |
| 278 | + return array_values( | |
| 279 | + array_filter( | |
| 280 | + $rows, | |
| 281 | + static function ( array $row ) use ( $sync_latched ): bool { | |
| 282 | + return $sync_latched || 'sync-latched' !== $row['phase']; | |
| 283 | + } | |
| 284 | + ) | |
| 285 | + ); | |
| 286 | + } | |
| 51 | 287 | |
| 52 | - /* | |
| 53 | - * Add JWT authentication filter. | |
| 54 | - * | |
| 55 | - * Hook order: plugins_loaded -> init (determine_current_user) -> rest_api_init | |
| 56 | - * | |
| 57 | - * This filter runs at priority 20 (after WordPress core's cookie auth at priority 10). | |
| 58 | - * It must be registered here (during plugins_loaded) because determine_current_user | |
| 59 | - * fires during 'init', which is BEFORE rest_api_init where our API class loads. | |
| 60 | - */ | |
| 61 | - add_filter( 'determine_current_user', array( $this, 'determine_current_user_early' ), 20 ); | |
| 288 | + /** | |
| 289 | + * Clear cached data that depends on the Pro license. | |
| 290 | + * | |
| 291 | + * @param mixed $value The new option value. | |
| 292 | + * @param mixed $old_value The previous option value (false when unset). | |
| 293 | + * | |
| 294 | + * @return mixed | |
| 295 | + */ | |
| 296 | + public static function remove_license_transient( $value, $old_value = false ) { | |
| 297 | + // Pro's updater can react to the update_plugins deletion by reading — | |
| 298 | + // and, when the stored instance id is blank, re-saving — the license | |
| 299 | + // option, which re-enters this filter. Without the guard that cycle is | |
| 300 | + // unbounded and OOMs the first license activation on a fresh install. | |
| 301 | + static $clearing = false; | |
| 302 | + if ( $clearing ) { | |
| 303 | + return $value; | |
| 304 | + } | |
| 305 | + $clearing = true; | |
| 306 | + delete_transient( 'woocommerce_pos_pro_license_status' ); | |
| 307 | + | |
| 308 | + // The update caches bind to the license key and activation state. A | |
| 309 | + // write that changes neither — e.g. Pro's read-side instance mint — | |
| 310 | + // must not wipe update_plugins: Pro reacts to that deletion by | |
| 311 | + // clearing its own update-data cache, which empties the payload of an | |
| 312 | + // update check that is in flight when the mint occurs. | |
| 313 | + $old = \is_array( $old_value ) ? $old_value : array(); | |
| 314 | + $new = \is_array( $value ) ? $value : array(); | |
| 315 | + if ( | |
| 316 | + (string) ( $old['key'] ?? '' ) !== (string) ( $new['key'] ?? '' ) | |
| 317 | + || ! empty( $old['activated'] ) !== ! empty( $new['activated'] ) | |
| 318 | + ) { | |
| 319 | + delete_site_transient( 'update_plugins' ); | |
| 320 | + } | |
| 321 | + $clearing = false; | |
| 322 | + | |
| 323 | + return $value; | |
| 62 | 324 | } |
| 63 | 325 | |
| 64 | 326 | /** |
| 65 | 327 | * Early authentication check for JWT tokens. |
| @@ -82,30 +344,14 @@ | ||
| 82 | 344 | if ( ! empty( $user_id ) ) { |
| 83 | 345 | return $user_id; |
| 84 | 346 | } |
| 85 | 347 | |
| 86 | - // Check for authorization token (header or param). | |
| 87 | - $auth_header = $this->get_auth_header_early(); | |
| 88 | - if ( ! \is_string( $auth_header ) || empty( $auth_header ) ) { | |
| 348 | + $authenticated_user_id = AuthService::instance()->authenticate_request(); | |
| 349 | + if ( false === $authenticated_user_id || is_wp_error( $authenticated_user_id ) ) { | |
| 89 | 350 | return $user_id; |
| 90 | 351 | } |
| 91 | 352 | |
| 92 | - // Extract Bearer token. | |
| 93 | - list( $token ) = sscanf( $auth_header, 'Bearer %s' ); | |
| 94 | - if ( ! $token ) { | |
| 95 | - return $user_id; | |
| 96 | - } | |
| 97 | - | |
| 98 | - // Validate token - this will fail for non-WCPOS tokens. | |
| 99 | - $auth_service = AuthService::instance(); | |
| 100 | - $decoded_token = $auth_service->validate_token( $token ); | |
| 101 | - | |
| 102 | - if ( is_wp_error( $decoded_token ) ) { | |
| 103 | - return $user_id; | |
| 104 | - } | |
| 105 | - | |
| 106 | - // Return the authenticated user ID. | |
| 107 | - return absint( $decoded_token->data->user->id ); | |
| 353 | + return $authenticated_user_id; | |
| 108 | 354 | } |
| 109 | 355 | |
| 110 | 356 | /** |
| 111 | 357 | * Load the required resources. |
| @@ -123,8 +369,13 @@ | ||
| 123 | 369 | public function init_rest_api(): void { |
| 124 | 370 | $is_wcpos_request = woocommerce_pos_request(); |
| 125 | 371 | |
| 126 | 372 | if ( $is_wcpos_request ) { |
| 373 | + if ( ! wcpos_request( 'header' ) && ! wcpos_request( 'query_var' ) ) { | |
| 374 | + // Namespace-detected only: routes still register, but surface | |
| 375 | + // that a proxy/WAF is stripping the X-WCPOS marker. | |
| 376 | + $this->log_unmarked_wcpos_rest_request(); | |
| 377 | + } | |
| 127 | 378 | new API(); |
| 128 | 379 | } else { |
| 129 | 380 | // Queue the registration at a later priority of the SAME |
| 130 | 381 | // rest_api_init pass this method runs on (priority 20), so |
| @@ -131,9 +382,8 @@ | ||
| 131 | 382 | // register_rest_route() executes during the action as WP requires. |
| 132 | 383 | // When this method is called outside the action (tests), the |
| 133 | 384 | // add_action is simply inert. |
| 134 | 385 | add_action( 'rest_api_init', array( $this, 'register_public_relay_routes' ), 30 ); |
| 135 | - $this->log_unmarked_wcpos_rest_request(); | |
| 136 | 386 | new WC_API(); |
| 137 | 387 | } |
| 138 | 388 | } |
| 139 | 389 | |
| @@ -150,9 +400,9 @@ | ||
| 150 | 400 | SHORT_NAME . '/v1', |
| 151 | 401 | '/print-jobs/relay-verification', |
| 152 | 402 | array( |
| 153 | 403 | 'methods' => 'GET', |
| 154 | - 'callback' => array( new API\Print_Jobs_Controller(), 'relay_verification' ), | |
| 404 | + 'callback' => array( new API\V1\Print_Jobs_Controller(), 'relay_verification' ), | |
| 155 | 405 | 'permission_callback' => '__return_true', |
| 156 | 406 | ) |
| 157 | 407 | ); |
| 158 | 408 | } |
| @@ -157,12 +407,13 @@ | ||
| 157 | 407 | ); |
| 158 | 408 | } |
| 159 | 409 | |
| 160 | 410 | /** |
| 161 | - * Log requests for a WCPOS namespace that omitted the required request marker. | |
| 411 | + * Log requests for a WCPOS namespace that omitted the request marker. | |
| 162 | 412 | * |
| 163 | - * This runs before WCPOS routes are registered, so it captures the otherwise | |
| 164 | - * silent rest_no_route response. Warnings are limited by API version to avoid | |
| 413 | + * Namespace detection registers the routes anyway; this surfaces that a | |
| 414 | + * proxy/WAF is stripping the X-WCPOS marker so misconfigured hosts stay | |
| 415 | + * visible in the logs. Warnings are limited by API version to avoid | |
| 165 | 416 | * allowing repeated unauthenticated requests to flood WooCommerce logs. |
| 166 | 417 | */ |
| 167 | 418 | private function log_unmarked_wcpos_rest_request(): void { |
| 168 | 419 | global $wp; |
| @@ -186,9 +437,9 @@ | ||
| 186 | 437 | return; |
| 187 | 438 | } |
| 188 | 439 | |
| 189 | 440 | set_transient( $transient, 1, 5 * MINUTE_IN_SECONDS ); |
| 190 | - Logger::warning( $route . ': missing WCPOS request marker.' ); | |
| 441 | + Logger::warning( $route . ': request marker missing (routes still registered via namespace detection).' ); | |
| 191 | 442 | } |
| 192 | 443 | |
| 193 | 444 | /** |
| 194 | 445 | * Adds 'wcpos' to the query variables allowed before processing. |
| @@ -207,46 +458,29 @@ | ||
| 207 | 458 | return $query_vars; |
| 208 | 459 | } |
| 209 | 460 | |
| 210 | 461 | /** |
| 211 | - * Allow pre-flight requests from WCPOS Desktop and Mobile Apps | |
| 212 | - * Note: pre-flight requests cannot have headers, so I can't filter by pos request | |
| 213 | - * See: https://fetch.spec.whatwg.org/#cors-preflight-fetch. | |
| 462 | + * Allow HEAD checks for WP API Link URL and server uptime. | |
| 214 | 463 | * |
| 215 | - * @param bool $served Whether the request has already been served. | |
| 216 | - * Default false. | |
| 217 | - * @param WP_HTTP_Response $result Result to send to the client. Usually a `WP_REST_Response`. | |
| 218 | - * @param WP_REST_Request $request Request used to generate the response. | |
| 219 | - * @param WP_REST_Server $server Server instance. | |
| 464 | + * This is the NON-REST lane and is not part of the REST wire contract | |
| 465 | + * ({@see Rest_Cors}): `send_headers` fires from `WP::main()`, which a REST | |
| 466 | + * request never reaches — core's `rest_api_loaded()` runs on | |
| 467 | + * `parse_request` and dies. What it serves is the app's site-discovery | |
| 468 | + * probe against an ordinary page: the app reads the `Link: | |
| 469 | + * <.../wp-json/>; rel="https://api.w.org/"` header cross-origin to find | |
| 470 | + * the REST root, which needs both headers below. Some servers turn HEAD | |
| 471 | + * into GET, hence the `?_method=head` query param rather than the method. | |
| 220 | 472 | * |
| 221 | - * @return bool $served | |
| 222 | - */ | |
| 223 | - public function rest_pre_serve_request( $served, WP_HTTP_Response $result, WP_REST_Request $request, WP_REST_Server $server ) { | |
| 224 | - if ( 'OPTIONS' == $request->get_method() ) { | |
| 225 | - $allow_headers = array( | |
| 226 | - 'Authorization', // For user-agent authentication with a server. | |
| 227 | - 'X-WP-Nonce', // WordPress-specific header, used for CSRF protection. | |
| 228 | - 'Content-Disposition', // Informs how to process the response data. | |
| 229 | - 'Content-MD5', // For verifying data integrity. | |
| 230 | - 'Content-Type', // Specifies the media type of the resource. | |
| 231 | - 'X-HTTP-Method-Override', // Used to override the HTTP method. | |
| 232 | - 'X-WCPOS', // Used to identify WCPOS requests. | |
| 233 | - ); | |
| 234 | - | |
| 235 | - $server->send_header( 'Access-Control-Allow-Origin', '*' ); | |
| 236 | - $server->send_header( 'Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE' ); | |
| 237 | - $server->send_header( 'Access-Control-Allow-Headers', implode( ', ', $allow_headers ) ); | |
| 238 | - } | |
| 239 | - | |
| 240 | - return $served; | |
| 241 | - } | |
| 242 | - | |
| 243 | - /** | |
| 244 | - * Allow HEAD checks for WP API Link URL and server uptime | |
| 245 | - * Fires once the requested HTTP headers for caching, content type, etc. have been sent. | |
| 473 | + * This is live, not legacy. The client calls it on every Connect: | |
| 474 | + * `packages/core/src/screens/auth/hooks/use-url-discovery.ts` issues | |
| 475 | + * `http.head()` against the site root, and | |
| 476 | + * `packages/hooks/src/use-http-client/use-http-client.tsx` sets | |
| 477 | + * `params._method = 'HEAD'` on every HEAD request (both in the client | |
| 478 | + * monorepo). That same client code deliberately omits the `X-WCPOS` | |
| 479 | + * marker for HEAD, so this handler cannot be marker-gated and must stay | |
| 480 | + * unconditional. 521ccb9a added it; the `?wcpos=1` gate it originally | |
| 481 | + * carried is long gone. | |
| 246 | 482 | * |
| 247 | - * FIXME: Why is Link header not exposed sometimes on my development machine? | |
| 248 | - * | |
| 249 | 483 | * @return void |
| 250 | 484 | */ |
| 251 | 485 | public function send_headers(): void { |
| 252 | 486 | // some server convert HEAD to GET method, so use this query param instead. |
| @@ -274,51 +508,101 @@ | ||
| 274 | 508 | } |
| 275 | 509 | } |
| 276 | 510 | |
| 277 | 511 | /** |
| 278 | - * Get authorization header/param value. | |
| 512 | + * Groups constructed so far in this request (test seam; see constructed_groups()). | |
| 279 | 513 | * |
| 280 | - * Checks multiple sources for the authorization token: | |
| 281 | - * 1. HTTP_AUTHORIZATION server variable (standard) | |
| 282 | - * 2. REDIRECT_HTTP_AUTHORIZATION (Apache CGI workaround) | |
| 283 | - * 3. authorization query parameter (for servers that strip auth headers) | |
| 284 | - * | |
| 285 | - * @return false|string The authorization value or false if not found. | |
| 514 | + * @var array<string, bool> | |
| 286 | 515 | */ |
| 287 | - private function get_auth_header_early() { | |
| 288 | - // Check HTTP_AUTHORIZATION (not empty - htaccess SetEnvIf can set empty value). | |
| 289 | - if ( ! empty( $_SERVER['HTTP_AUTHORIZATION'] ) ) { | |
| 290 | - return sanitize_text_field( wp_unslash( $_SERVER['HTTP_AUTHORIZATION'] ) ); | |
| 291 | - } | |
| 516 | + private static array $constructed = array(); | |
| 292 | 517 | |
| 293 | - // Check REDIRECT_HTTP_AUTHORIZATION (Apache CGI). | |
| 294 | - if ( ! empty( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) ) { | |
| 295 | - return sanitize_text_field( wp_unslash( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) ); | |
| 296 | - } | |
| 297 | - | |
| 298 | - // Check authorization query param. | |
| 299 | - if ( ! empty( $_GET['authorization'] ) ) { | |
| 300 | - return sanitize_text_field( wp_unslash( $_GET['authorization'] ) ); | |
| 301 | - } | |
| 302 | - | |
| 303 | - return false; | |
| 304 | - } | |
| 305 | - | |
| 306 | 518 | /** |
| 307 | - * Common initializations. | |
| 519 | + * Common initializations, by request lane. | |
| 520 | + * | |
| 521 | + * Every request gets the services whose hooks WooCommerce consults on a | |
| 522 | + * plain shopper page BEFORE any order exists: translations, the product | |
| 523 | + * visibility filters, the order statuses and the read-side order filters | |
| 524 | + * (My Account renders POS orders), the gateway registration (WooCommerce | |
| 525 | + * builds its gateway list on cart pages) and the reserved-stock filter | |
| 526 | + * (POS drafts must reduce online availability at add-to-cart time). | |
| 527 | + * | |
| 528 | + * Everything else is constructed only on the lanes that use it, and the | |
| 529 | + * order-event services additionally on the first order write of ANY request | |
| 530 | + * ({@see ensure_order_services()}), so the lane classifier is an | |
| 531 | + * optimisation rather than a correctness gate. Measured 2026-09-03: a | |
| 532 | + * storefront page loaded ~80 plugin files and 22 objects for hooks that | |
| 533 | + * never fire there (see .claude/research/2026-09-03-lazy-service-construction-spec.md). | |
| 308 | 534 | */ |
| 309 | 535 | private function init_common(): void { |
| 536 | + self::$constructed['always'] = true; | |
| 537 | + | |
| 310 | 538 | // init the Services. |
| 311 | 539 | SettingsService::instance(); |
| 312 | 540 | AuthService::instance(); |
| 313 | - Extensions::instance(); | |
| 314 | - Receipt_Snapshot_Store::instance(); | |
| 315 | 541 | |
| 316 | - // init other functionality needed by both frontend and admin. | |
| 542 | + // Needed on every lane, including a plain storefront page. | |
| 317 | 543 | new i18n(); |
| 318 | 544 | new Gateways(); |
| 319 | 545 | new Products(); |
| 320 | 546 | new Orders(); |
| 547 | + Services\Stock_Validator::instance(); | |
| 548 | + Services\Order_Write_Intent::register(); | |
| 549 | + | |
| 550 | + if ( Services\Request_Lane::is_storefront() ) { | |
| 551 | + // Order-event services arrive on the first order write, if any. | |
| 552 | + self::arm_order_services(); | |
| 553 | + return; | |
| 554 | + } | |
| 555 | + | |
| 556 | + self::ensure_order_services(); | |
| 557 | + self::construct_pos_services(); | |
| 558 | + } | |
| 559 | + | |
| 560 | + /** | |
| 561 | + * Services only POS, admin, REST, cron and CLI requests use. | |
| 562 | + */ | |
| 563 | + private static function construct_pos_services(): void { | |
| 564 | + if ( isset( self::$constructed['pos'] ) ) { | |
| 565 | + return; | |
| 566 | + } | |
| 567 | + self::$constructed['pos'] = true; | |
| 568 | + Extensions::instance(); | |
| 569 | + new Services\Decimal_Quantities(); | |
| 570 | + new Services\Customer_Meta_Parity(); | |
| 571 | + } | |
| 572 | + | |
| 573 | + /** | |
| 574 | + * Hook the order-event services to the first order write of the request. | |
| 575 | + * | |
| 576 | + * Every WooCommerce order write — create, update, status transition, | |
| 577 | + * `payment_complete()`, refund — goes through `WC_Abstract_Order::save()`, | |
| 578 | + * which fires `woocommerce_before_order_object_save` before the data store | |
| 579 | + * writes and before `woocommerce_new_order` / `woocommerce_order_status_changed` | |
| 580 | + * / `woocommerce_payment_complete` fire. Priority 0 there means every | |
| 581 | + * observer exists before any order is written — on a webhook, a cron | |
| 582 | + * spawned from a page view, a third-party plugin creating an order on | |
| 583 | + * `template_redirect`, or a lane the classifier got wrong. Nothing in the | |
| 584 | + * order group listens to trash or delete, so those need no arming. | |
| 585 | + */ | |
| 586 | + private static function arm_order_services(): void { | |
| 587 | + add_action( 'woocommerce_before_order_object_save', array( self::class, 'ensure_order_services' ), 0, 0 ); | |
| 588 | + } | |
| 589 | + | |
| 590 | + /** | |
| 591 | + * Construct the order-event services exactly once per request. | |
| 592 | + * | |
| 593 | + * Idempotent and safe to call after `init`; each service handles its own | |
| 594 | + * late registration. Fires `woocommerce_pos_order_services_ready` once so | |
| 595 | + * Pro and extensions can construct their own order-event services at the | |
| 596 | + * same moment on every lane. | |
| 597 | + */ | |
| 598 | + public static function ensure_order_services(): void { | |
| 599 | + if ( isset( self::$constructed['order'] ) ) { | |
| 600 | + return; | |
| 601 | + } | |
| 602 | + self::$constructed['order'] = true; | |
| 603 | + | |
| 604 | + Receipt_Snapshot_Store::instance(); | |
| 321 | 605 | new Emails(); |
| 322 | 606 | new Templates(); |
| 323 | 607 | new Services\Print_Job_Service(); |
| 324 | 608 | new Services\Cloud_Print_Trigger_Service(); |
| @@ -323,18 +607,61 @@ | ||
| 323 | 607 | new Services\Print_Job_Service(); |
| 324 | 608 | new Services\Cloud_Print_Trigger_Service(); |
| 325 | 609 | new Services\Cloud_Print_Submit_Service(); |
| 326 | 610 | new Services\Cloud_Print_Relay_Service(); |
| 611 | + | |
| 612 | + /** | |
| 613 | + * Fires once per request when the POS order-event services exist: | |
| 614 | + * eagerly on POS, admin, REST, cron and CLI requests (from this | |
| 615 | + * plugin's `init` callback at priority 10), and on a storefront | |
| 616 | + * request the moment the first order is about to be written. | |
| 617 | + * | |
| 618 | + * Because the eager firing happens at `init` priority 10, a listener | |
| 619 | + * added later than that (for example from another plugin's `init` | |
| 620 | + * callback at priority 20) must check `did_action()` first and | |
| 621 | + * construct immediately when the action has already fired. | |
| 622 | + * | |
| 623 | + * @since 1.10.8 | |
| 624 | + */ | |
| 625 | + do_action( 'woocommerce_pos_order_services_ready' ); | |
| 327 | 626 | } |
| 328 | 627 | |
| 329 | 628 | /** |
| 629 | + * Which service groups this request has constructed: 'always', 'order', 'pos'. | |
| 630 | + * | |
| 631 | + * @internal Test seam. | |
| 632 | + * | |
| 633 | + * @return string[] | |
| 634 | + */ | |
| 635 | + public static function constructed_groups(): array { | |
| 636 | + return array_keys( self::$constructed ); | |
| 637 | + } | |
| 638 | + | |
| 639 | + /** | |
| 640 | + * Forget which groups were constructed. Tests only. | |
| 641 | + * | |
| 642 | + * @internal | |
| 643 | + */ | |
| 644 | + public static function reset_request_state(): void { | |
| 645 | + self::$constructed = array(); | |
| 646 | + Services\Request_Lane::reset(); | |
| 647 | + } | |
| 648 | + | |
| 649 | + /** | |
| 330 | 650 | * Frontend specific initializations. |
| 331 | 651 | */ |
| 332 | 652 | private function init_frontend(): void { |
| 333 | - if ( ! is_admin() ) { | |
| 653 | + if ( is_admin() ) { | |
| 654 | + return; | |
| 655 | + } | |
| 656 | + // The public receipt shortcode and the My Account receipt action are | |
| 657 | + // storefront features; they construct the template services when used. | |
| 658 | + new Storefront_Receipts(); | |
| 659 | + if ( ! Services\Request_Lane::is_storefront() ) { | |
| 660 | + // The POS routes (rewrite rules, checkout context, order-pay and | |
| 661 | + // coupon forms) only matter on requests the classifier saw as POS. | |
| 334 | 662 | new Template_Router(); |
| 335 | 663 | new Form_Handler(); |
| 336 | - new Storefront_Receipts(); | |
| 337 | 664 | } |
| 338 | 665 | } |
| 339 | 666 | |
| 340 | 667 | /** |
| @@ -345,9 +672,9 @@ | ||
| 345 | 672 | // Register AJAX handler before the branch so it's available during AJAX requests. |
| 346 | 673 | add_action( 'wp_ajax_wcpos_track_upgrade_click_ajax', array( Menu::class, 'handle_upgrade_click_ajax' ) ); |
| 347 | 674 | add_action( 'admin_post_wcpos_track_upgrade_click', array( Menu::class, 'handle_upgrade_click_redirect' ) ); |
| 348 | 675 | |
| 349 | - if ( \defined( 'DOING_AJAX' ) && DOING_AJAX ) { | |
| 676 | + if ( wp_doing_ajax() ) { | |
| 350 | 677 | new AJAX(); |
| 351 | 678 | } else { |
| 352 | 679 | new Admin(); |
| 353 | 680 | } |
| @@ -369,7 +696,17 @@ | ||
| 369 | 696 | } |
| 370 | 697 | |
| 371 | 698 | // wePOS alters the WooCommerce REST API, breaking the expected schema |
| 372 | 699 | // It's very bad form on their part, but we need to work around it. |
| 373 | - new Integrations\WePOS(); | |
| 700 | + // Its only hook is admin_init (a conflict notice), so admin lane only. | |
| 701 | + if ( is_admin() ) { | |
| 702 | + new Integrations\WePOS(); | |
| 703 | + } | |
| 704 | + | |
| 705 | + // WooCommerce Tax - https://wordpress.org/plugins/woocommerce-services/ | |
| 706 | + // Its class exists whenever the plugin is active, but its callbacks are | |
| 707 | + // only hooked when automated taxes are on and the store country is | |
| 708 | + // supported, so the integration looks them up on the hooks at | |
| 709 | + // recalculation time instead of gating on the class here. | |
| 710 | + new Integrations\WooCommerce_Tax(); | |
| 374 | 711 | } |
| 375 | 712 | } |