| @@ -21,204 +21,269 @@ | ||
| 21 | 21 | * Init class. |
| 22 | 22 | */ |
| 23 | 23 | class Init { |
| 24 | 24 | /** |
| 25 | - * Constructor — the plugin's entire `plugins_loaded` hook wiring. | |
| 25 | + * Observer awaiting the constructor's non-hook seed step. | |
| 26 | 26 | * |
| 27 | - * Reached from {@see Activator::init()}, which runs on `plugins_loaded` at the | |
| 28 | - * default priority 10. Everything that must exist before `init` fires — most | |
| 29 | - * importantly the `determine_current_user` pair — has to be registered here. | |
| 27 | + * @var Sync\Visibility_Observer|null | |
| 28 | + */ | |
| 29 | + private $visibility_observer; | |
| 30 | + | |
| 31 | + /** | |
| 32 | + * Install the ordered wiring declared by {@see hook_rows()}. | |
| 30 | 33 | * |
| 31 | - * NOT PURE WIRING. Constructing this class also, in statement order: | |
| 32 | - * `require_once`s `wcpos-functions.php` and `wcpos-store-functions.php`; | |
| 33 | - * registers the `wc_pos_user_uuid_locks` global cache group; READS the sync | |
| 34 | - * schema-latch option; WRITES options through | |
| 35 | - * `Config_Fingerprint::maybe_cleanup_legacy_options()` (one-time, latched on | |
| 36 | - * its own version option); and SCHEDULES a daily cron event through | |
| 37 | - * `Sync_Journal_Purge::register_hooks()`. Anything that constructs `Init` — | |
| 38 | - * a test included — inherits all of that. | |
| 39 | - * | |
| 40 | - * ## How to read the ordering table | |
| 41 | - * | |
| 42 | - * A priority number decides ordering on its own, wherever in this method it | |
| 43 | - * happens to be written. Statement order is load-bearing ONLY when two | |
| 44 | - * callbacks share a hook AND a priority: WordPress then runs them in | |
| 45 | - * registration order. Exactly one such pair exists here, and it is marked | |
| 46 | - * ORDER-CRITICAL (STATEMENT ORDER) below — do not move it. | |
| 47 | - * | |
| 48 | - * Priorities that live in the callee (`Meta_Normalizer` at 5, `Revision` at 9, | |
| 49 | - * the proxy stampers at 10) are listed at the value they actually register, | |
| 50 | - * not at the position of the call in this method. Reordering those statements | |
| 51 | - * changes nothing; changing those numbers changes everything. | |
| 52 | - * | |
| 53 | - * "Why" is recovered from `git log -S` / `git blame` where a reason was | |
| 54 | - * recorded. Where none was, the entry says **unknown** rather than guessing. | |
| 55 | - * | |
| 56 | - * ## Ordering table | |
| 57 | - * | |
| 58 | - * | # | Hook | Callback | Pri | Order | Why that priority | | |
| 59 | - * |---|------|----------|-----|-------|-------------------| | |
| 60 | - * | 1 | `activated_plugin`, `upgrader_process_complete`, `admin_enqueue_scripts`, `admin_notices`, `rest_api_init` | `Admin\Consent` (5 callbacks) | 10 | irrelevant | Default. What matters is that `Consent` is built during `plugins_loaded`, so its lifecycle hooks exist before an activation/update request fires them. | | |
| 61 | - * | 2 | `woocommerce_pos_rest_api_controllers` | `Sync\Api::register_controllers` | 10 | irrelevant | Default; sole callback. | | |
| 62 | - * | 3 | `wcpos_integrity_digest_rebuild` | `Sync\Integrity_Digest::run_scheduled_rebuild` | 10 | irrelevant | Default; sole callback. Registered OUTSIDE the schema latch, so an already-scheduled rebuild still has a callback while the latch is down. | | |
| 63 | - * | 4 | `woocommerce_pos_sync_proxy_response`, `..._serialized_product`, `..._serialized_order` | `Sync\Meta_Normalizer::normalize` | **5** | **ORDER-CRITICAL** | Must precede `Revision` at 9 so the stamped revision bytes equal what the write path recomputes from a bare `wc/v3` re-read. See `Sync\Augmentation_Pipeline` class docblock and `Sync\Meta_Normalizer::register_hooks()`. Kept out of the pipeline because it also serves the ORDER lane. | | |
| 64 | - * | 5 | `woocommerce_pos_sync_serialized_order` | `Sync\Pos_Uuid::stamp_serialized_record` | 10 | order-critical (by number) | After `Meta_Normalizer` at 5, in step with the product lane's stampers. | | |
| 65 | - * | 6 | `woocommerce_pos_sync_order_pull_payloads` | `Sync\Integrity_Digest::stamp_proxy_order_digests` | 10 | irrelevant | Default; sole callback on that filter. | | |
| 66 | - * | 7 | `woocommerce_pos_sync_proxy_response` | `Sync\Revision::stamp_proxy_revisions` (via `Augmentation_Pipeline::install()`) | **9** | **ORDER-CRITICAL** | Between `Meta_Normalizer` (5) and the uuid/digest stampers (10). Revision must hash the normalized-but-not-yet-augmented payload. | | |
| 67 | - * | 8 | `woocommerce_pos_sync_proxy_response`, `..._serialized_product` | `Proxy_Uuid_Stamper`, `Integrity_Digest` digest stampers, pipeline projections | 10 | order-critical (by number) | Preserved verbatim from the hand-wiring the pipeline replaced, so third-party code hooking either public filter still runs where it always did. | | |
| 68 | - * | 9 | `woocommerce_before_product_object_save`, `woocommerce_before_product_variation_object_save` | `Sync\Pos_Uuid::stamp_on_save` | 10 | irrelevant | Default. The HOOK is the design (before the data store writes, so the uuid lands in the same save); the number is not. Registered unconditionally — identity is core, not an observer. | | |
| 69 | - * | 9a | `untrashed_post`, `woocommerce_untrash_order` | `Sync\Pos_Uuid::recheck_ownership_after_untrash`, `::recheck_order_ownership_after_untrash` | 10 | irrelevant | Default. Re-proves uuid ownership when a record leaves the trash — the one seam a native restore passes through (#1805, ADR 0038). Unconditional for the same reason as row 9; the journal and digest observers (rows 10, 12) share both hooks at the same priority once the latch is set, and nothing depends on the order. | | |
| 70 | - * | 10 | 32 catalogue/customer/order hooks, plus `shutdown` | `Sync\Sync_Journal` (34 callbacks) | 10 (`shutdown` at `PHP_INT_MAX`) | `shutdown`: order-critical (by number) | Default throughout. `woocommerce_update_order` only MARKS the order dirty; the `hook:update` row lands on `flush_pending_order_updates()` — at `shutdown`, before any other-origin row for that order, or when a different order is saved — so one online checkout writes one update row, not eleven. The shutdown flush runs LAST because WooCommerce saves the customer at 10 and the session at 20; a save those trigger after the flush is written immediately. | | |
| 71 | - * | 10b | `delete_option` plus `pre_update_option_*`, `update_option_*`, `add_option_*`, `delete_option_*` for the two `Pos_Visibility::source_options()` | `Sync\Visibility_Observer` (9 callbacks) | 10 | irrelevant | Default. Appends the journal row for a record entering or leaving the POS servable set — the transition the sequence-log stream relies on, since it drops a hidden record's update rows. `delete_option` is the generic PRE-delete action (the per-option form fires after) and is gated on the option name inside the callback. Registered after `Sync_Journal` only because it writes through it; the constructor also runs the observer's one-time tombstone seed. | | |
| 72 | - * | 11 | `wcpos_sync_journal_purge` | `Sync\Sync_Journal_Purge::run_purge` | 10 | irrelevant | Cron callback; sole listener. This call also SCHEDULES the daily event. | | |
| 73 | - * | 12 | 21 catalogue/customer/order hooks (a subset of row 10's), plus `shutdown` | `Sync\Integrity_Digest` | 10 (`shutdown` at `PHP_INT_MAX`) | unknown | Default. Shares every one of its hooks with `Sync_Journal` at the same priority, so the journal always runs first — no code found that depends on that, but nothing pins it either. Every save — product, variation, customer, order — only MARKS the digest dirty; the upsert lands on `flush_pending_digests()` at `shutdown`, before any `Digest_Index::read_digests()`, or when the queue holds 50 records. | | |
| 74 | - * | 13 | `init` | `Init::init` | 10 | **ORDER-CRITICAL, CROSS-PLUGIN** | Default. **Pro registers its own `init` at 20** (`woocommerce-pos-pro/includes/Init.php:32`) so free's services exist first. Raising free's number silently breaks Pro; nothing on either side tests it. | | |
| 75 | - * | 14 | `rest_api_init` | `Init::init_rest_api` | **20** | **ORDER-CRITICAL, CROSS-PLUGIN** | Free's own reason: unknown — the number dates to the initial commit (8f2b9eac, 2021-03-16). It is load-bearing anyway: **Pro registers `rest_api_init` at 9**, commented "Before the free version" (`woocommerce-pos-pro/includes/Init.php:33`). Untested on both sides. | | |
| 76 | - * | 15 | `query_vars` | `Init::query_vars` | 10 | irrelevant | Default; appends one var. | | |
| 77 | - * | 16 | `pre_update_option_woocommerce_pos_pro_settings_license` | `Init::remove_license_transient` | 10 | irrelevant | Default. The reentrancy guard, not the priority, is what makes it safe (f33b8d655). | | |
| 78 | - * | 17 | ~~`rest_pre_serve_request`~~ | *(removed)* | — | — | Init no longer publishes any part of the REST wire contract. This registration and its handler moved to `Rest_Cors::register_hooks()`, which registers at **20** — after core's `rest_send_cors_headers` at 10 — so WCPOS is the last writer on the lanes it owns. The old `5` had no recorded reason; the new number does. | | |
| 79 | - * | 18 | `send_headers` | `Init::send_headers` | 99 | unknown | Introduced by 62da70551 ("fix WPSEO integration"). The commit records no reason for the number beyond running late. | | |
| 80 | - * | 19 | `send_headers` | `Init::remove_x_frame_options` | **9999** | **ORDER-CRITICAL** | Must run AFTER security plugins have set `X-Frame-Options`, because it works by `header_remove()` (80ee545a5). A smaller number lets the plugin set the header again afterwards. | | |
| 81 | - * | 20 | `determine_current_user` | `Services\Core_Order_Audit_Guard::record_prior_authentication` | **20** | **ORDER-CRITICAL (STATEMENT ORDER)** | See below. | | |
| 82 | - * | 21 | `rest_pre_dispatch` | `Services\Core_Order_Audit_Guard::rest_pre_dispatch` | 10 | irrelevant | Default; reads what row 20 recorded. | | |
| 83 | - * | 22 | `woocommerce_update_coupon` | `Sync\Coupon_Modified_Date::touch` | 10 | irrelevant | Default. `Sync_Journal::record_coupon_updated` shares the hook and priority (row 10) and is registered first, but the journal timestamps rows with the wall clock, not the coupon's `post_modified`, so neither ordering changes an outcome. | | |
| 84 | - * | 23 | `determine_current_user` | `Init::determine_current_user_early` | **20** | **ORDER-CRITICAL (STATEMENT ORDER)** | See below. | | |
| 85 | - * | 24 | `admin_init` | `Services\Lifecycle_Events::flush_pending`, `::maybe_schedule_refresh` | 10 | irrelevant | Default. `admin_init` because both need a fully booted admin request: one sends install/upgrade events recorded before the plugin was loaded enough to send them, the other schedules row 25. Both check consent first and cost nothing on a site that opted out. | | |
| 86 | - * | 25 | `wcpos_analytics_group_refresh` | `Services\Lifecycle_Events::refresh_group_properties` | 10 | irrelevant | Default; sole listener. Unlike row 11, this call does NOT schedule the event — scheduling lives in row 24 so that withdrawing consent unschedules it. | | |
| 87 | - * | 26 | `rest_request_after_callbacks` | `Services\Error_Reporter::filter_rest_request_after_callbacks` | **999** | order-critical (by number) | Runs late so the response status it reports is the one the client receives. | | |
| 88 | - * | |
| 89 | - * ## The one pair where statement order is the whole mechanism | |
| 90 | - * | |
| 91 | - * Rows 20 and 23 share `determine_current_user` AND priority 20, so insertion | |
| 92 | - * order — and nothing else — decides which runs first. 20 puts both after | |
| 93 | - * WordPress core's own handlers, which `default-filters.php` registers before | |
| 94 | - * any plugin loads: `wp_validate_auth_cookie` at 10, then | |
| 95 | - * `wp_validate_logged_in_cookie` and `wp_validate_application_password`, both | |
| 96 | - * at 20 and therefore both ahead of these two. | |
| 97 | - * | |
| 98 | - * The guard must run FIRST. It records into `pre_wcpos_user_id` whichever user | |
| 99 | - * some EARLIER filter had already authenticated; a non-zero value means the | |
| 100 | - * request proved itself with a cookie or application password, so | |
| 101 | - * `Core_Order_Audit_Guard::is_wcpos_jwt_authenticated()` returns false and the | |
| 102 | - * request keeps its normal power over order meta. | |
| 103 | - * | |
| 104 | - * Swap the two statements and the guard records the user WCPOS's own JWT filter | |
| 105 | - * just authenticated. `pre_wcpos_user_id` is then non-zero on every | |
| 106 | - * token-authenticated request, `is_wcpos_jwt_authenticated()` returns false for | |
| 107 | - * all of them, and forged `_pos_*` audit meta on `/wc/v3/orders` is accepted. | |
| 108 | - * It fails OPEN, silently, on a route no smoke test touches. | |
| 109 | - * | |
| 110 | - * Pinned by `tests/includes/Test_Init_Hook_Wiring.php`, which asserts the two | |
| 111 | - * callbacks' ARRAY POSITIONS inside `callbacks[20]` — asserting priorities | |
| 112 | - * would pass on the broken order. | |
| 34 | + * Non-hook setup stays here, interleaved at its original registration boundaries. | |
| 113 | 35 | */ |
| 114 | 36 | public function __construct() { |
| 115 | - // global helper functions. | |
| 116 | 37 | require_once PLUGIN_PATH . 'includes/wcpos-functions.php'; |
| 117 | 38 | require_once PLUGIN_PATH . 'includes/wcpos-store-functions.php'; |
| 118 | 39 | wp_cache_add_global_groups( 'wc_pos_user_uuid_locks' ); |
| 119 | 40 | |
| 120 | - // Tracking consent pop-up + callout. Registered here (during | |
| 121 | - // plugins_loaded) so its lifecycle hooks (activated_plugin, | |
| 122 | - // upgrader_process_complete) are in place before those actions | |
| 123 | - // fire on a plugin activation or update request. | |
| 124 | - new Consent(); | |
| 125 | - add_filter( 'woocommerce_pos_rest_api_controllers', array( \WCPOS\WooCommercePOS\Sync\Api::class, 'register_controllers' ) ); | |
| 126 | - add_action( \WCPOS\WooCommercePOS\Sync\Integrity_Digest::REBUILD_HOOK, array( \WCPOS\WooCommercePOS\Sync\Integrity_Digest::class, 'run_scheduled_rebuild' ) ); | |
| 127 | - // Gate on the schema latch, not a live Health probe: the latch is only | |
| 128 | - // set AFTER install verified every table (latch-after-verify), so a | |
| 129 | - // per-request SHOW TABLES sweep buys nothing — and a table lost after | |
| 130 | - // latching is already survivable (observer writes fail open and the | |
| 131 | - // REST health gate 503s the sync endpoints). | |
| 132 | - $sync_schema_latched = \WCPOS\WooCommercePOS\Sync\Api::SCHEMA_VERSION === get_option( \WCPOS\WooCommercePOS\Sync\Api::SCHEMA_OPTION, null ); | |
| 133 | - if ( $sync_schema_latched ) { | |
| 134 | - // Normalize structured meta at priority 5, before revision stamps at 9 | |
| 135 | - // and UUID, digest, and variable-price stamps at priority 10. Kept out | |
| 136 | - // of the augmentation pipeline because it also serves the order lane. | |
| 137 | - \WCPOS\WooCommercePOS\Sync\Meta_Normalizer::register_hooks(); | |
| 138 | - add_filter( 'woocommerce_pos_sync_serialized_order', array( \WCPOS\WooCommercePOS\Sync\Pos_Uuid::class, 'stamp_serialized_record' ), 10, 3 ); | |
| 139 | - // ONE seam for both product read lanes: the batch catalog proxy and the | |
| 140 | - // per-object serializer. Every stamper is declared once inside; both | |
| 141 | - // public filter names stay live as projections of it. The order pull | |
| 142 | - // lane's digest stamper is wired there too — it was hand-added here, | |
| 143 | - // under this same latch, which made the pipeline's single-wiring-site | |
| 144 | - // claim untrue. | |
| 145 | - \WCPOS\WooCommercePOS\Sync\Augmentation_Pipeline::install(); | |
| 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 | + } | |
| 146 | 57 | } |
| 58 | + } | |
| 147 | 59 | |
| 148 | - // Identity is core, not an observer benchmark variable: every product is | |
| 149 | - // born with a UUID even before the schema latch is healthy. The before-save | |
| 150 | - // hook writes it in the same save. | |
| 151 | - \WCPOS\WooCommercePOS\Sync\Pos_Uuid::register_hooks(); | |
| 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 | + ); | |
| 152 | 277 | |
| 153 | - if ( $sync_schema_latched ) { | |
| 154 | - ( new \WCPOS\WooCommercePOS\Sync\Sync_Journal() )->register_hooks(); | |
| 155 | - $visibility_observer = new \WCPOS\WooCommercePOS\Sync\Visibility_Observer(); | |
| 156 | - $visibility_observer->register_hooks(); | |
| 157 | - $visibility_observer->maybe_seed_hidden_tombstones(); | |
| 158 | - ( new \WCPOS\WooCommercePOS\Sync\Sync_Journal_Purge() )->register_hooks(); | |
| 159 | - ( new \WCPOS\WooCommercePOS\Sync\Integrity_Digest() )->register_hooks(); | |
| 160 | - } | |
| 161 | - | |
| 162 | - ( new \WCPOS\WooCommercePOS\Sync\Config_Fingerprint() )->maybe_cleanup_legacy_options(); | |
| 163 | - | |
| 164 | - // Init hooks. | |
| 165 | - add_action( 'init', array( $this, 'init' ) ); | |
| 166 | - add_action( 'rest_api_init', array( $this, 'init_rest_api' ), 20 ); | |
| 167 | - add_filter( 'query_vars', array( $this, 'query_vars' ) ); | |
| 168 | - | |
| 169 | - // Remove this once Pro settings have been moved to the new settings service. | |
| 170 | - add_filter( 'pre_update_option_woocommerce_pos_pro_settings_license', array( self::class, 'remove_license_transient' ), 10, 2 ); | |
| 171 | - | |
| 172 | - // The REST wire contract — CORS and shared-cache defeat — has a single | |
| 173 | - // owner. Registered unconditionally, from here rather than from the | |
| 174 | - // X-WCPOS-gated API class, because preflights carry no marker and the | |
| 175 | - // relay's consent route is served without constructing API. | |
| 176 | - Rest_Cors::register_hooks(); | |
| 177 | - | |
| 178 | - // Non-REST API discoverability: the HEAD probe against the homepage. | |
| 179 | - add_action( 'send_headers', array( $this, 'send_headers' ), 99, 1 ); | |
| 180 | - add_action( 'send_headers', array( $this, 'remove_x_frame_options' ), 9999, 1 ); | |
| 181 | - | |
| 182 | - /* | |
| 183 | - * Add the global JWT authentication filter and its core-route audit guard. | |
| 184 | - * | |
| 185 | - * Hook order: plugins_loaded -> init (determine_current_user) -> rest_api_init | |
| 186 | - * | |
| 187 | - * This filter runs at priority 20, after WordPress core's cookie auth handlers. | |
| 188 | - * It must be registered here (during plugins_loaded) because determine_current_user | |
| 189 | - * fires during 'init', which is BEFORE rest_api_init where our API class loads. | |
| 190 | - * Because it authenticates WCPOS Bearer tokens on EVERY | |
| 191 | - * REST request (marked or not), the audit-meta guard for core routes | |
| 192 | - * must be registered just as unconditionally — never from the | |
| 193 | - * X-WCPOS-gated API class, whose marker an attacker simply omits. | |
| 194 | - * Registering it first lets its priority-20 provenance filter run after | |
| 195 | - * core's cookie handlers but before WCPOS's JWT filter. | |
| 196 | - */ | |
| 197 | - ( new Services\Core_Order_Audit_Guard() )->register_hooks(); | |
| 198 | - | |
| 199 | - // Coupon post-date touch. Unconditional and lane-agnostic on purpose: a | |
| 200 | - // meta-only coupon edit (amount, discount_type, usage limits) never moves | |
| 201 | - // post_modified, and the client's catalogue replication is date-based | |
| 202 | - // (?modified_after, filtered by WooCommerce on post_modified_gmt), so an | |
| 203 | - // untouched coupon is invisible to every other till. That is true whether | |
| 204 | - // the edit came from the POS, wp-admin, WP-CLI or another plugin — so this | |
| 205 | - // sits outside the schema latch above because it does not use the v2 sync | |
| 206 | - // tables. | |
| 207 | - \WCPOS\WooCommercePOS\Sync\Coupon_Modified_Date::register_hooks(); | |
| 208 | - | |
| 209 | - add_filter( 'determine_current_user', array( $this, 'determine_current_user_early' ), 20 ); | |
| 210 | - | |
| 211 | - // Install lifecycle reporting. Registered last: it adds no filter that | |
| 212 | - // anything else orders against, and appending keeps the ordering table | |
| 213 | - // above in statement order. Deliberately NOT before the pair above — | |
| 214 | - // rows 20 and 23 are decided by insertion order alone. | |
| 215 | - ( new Services\Lifecycle_Events() )->register_hooks(); | |
| 216 | - | |
| 217 | - // Consent-gated Sentry error reporting (issue #1811). Registered last for | |
| 218 | - // the same reason as Lifecycle_Events: nothing orders against it. Its REST | |
| 219 | - // filter runs at 999 so the status it reports is the one the client receives. | |
| 220 | - Services\Error_Reporter::instance()->register_hooks(); | |
| 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 | + ); | |
| 221 | 286 | } |
| 222 | 287 | |
| 223 | 288 | /** |
| 224 | 289 | * Clear cached data that depends on the Pro license. |
| @@ -479,8 +544,9 @@ | ||
| 479 | 544 | new Gateways(); |
| 480 | 545 | new Products(); |
| 481 | 546 | new Orders(); |
| 482 | 547 | Services\Stock_Validator::instance(); |
| 548 | + Services\Order_Write_Intent::register(); | |
| 483 | 549 | |
| 484 | 550 | if ( Services\Request_Lane::is_storefront() ) { |
| 485 | 551 | // Order-event services arrive on the first order write, if any. |
| 486 | 552 | self::arm_order_services(); |