| @@ -7,11 +7,13 @@ | ||
| 7 | 7 | |
| 8 | 8 | namespace WCPOS\WooCommercePOS\API\V2; |
| 9 | 9 | |
| 10 | 10 | use WCPOS\WooCommercePOS\API\V2\Writers\Collection_Writer_Resolver; |
| 11 | +use WCPOS\WooCommercePOS\Services\Permission_Rules; | |
| 11 | 12 | use WCPOS\WooCommercePOS\Services\Tax_Id_Types; |
| 12 | 13 | use WCPOS\WooCommercePOS\Sync\Api; |
| 13 | 14 | use WCPOS\WooCommercePOS\Sync\Collections; |
| 15 | +use WCPOS\WooCommercePOS\Sync\Create_Identity; | |
| 14 | 16 | use WCPOS\WooCommercePOS\Sync\Endpoint_Permissions; |
| 15 | 17 | use WCPOS\WooCommercePOS\Sync\Header_Mirror; |
| 16 | 18 | use WCPOS\WooCommercePOS\Sync\Meta_Normalizer; |
| 17 | 19 | use WCPOS\WooCommercePOS\Sync\Mutation_Store; |
| @@ -43,8 +45,9 @@ | ||
| 43 | 45 | * live in an injected mutation store, so the apply logic is unit-testable with a |
| 44 | 46 | * fake store + a stubbed `rest_do_request`. |
| 45 | 47 | */ |
| 46 | 48 | class Write_Controller extends WP_REST_Controller { |
| 49 | + | |
| 47 | 50 | // Our gate (capability + F13 health); forwarded writes scope the client-tier grant below. |
| 48 | 51 | use Endpoint_Permissions; |
| 49 | 52 | |
| 50 | 53 | |
| @@ -50,9 +53,12 @@ | ||
| 50 | 53 | |
| 51 | 54 | /** @var mixed Duck-typed mutation store; tests inject an in-memory implementation. */ |
| 52 | 55 | private $store; |
| 53 | 56 | |
| 57 | + /** @var Create_Identity Shared proof for fresh and poisoned creates. */ | |
| 58 | + private Create_Identity $identity; | |
| 54 | 59 | |
| 60 | + | |
| 55 | 61 | /** |
| 56 | 62 | * collection => wc/v3 route + how its uuid→id is resolved. ONE table, not |
| 57 | 63 | * per-collection controllers. Only collections whose resolver is correct AND |
| 58 | 64 | * exercised are exposed; the rest stay out until their phase: |
| @@ -97,8 +103,9 @@ | ||
| 97 | 103 | } |
| 98 | 104 | |
| 99 | 105 | public function __construct( $store = null ) { |
| 100 | 106 | $this->store = $store ? $store : new Mutation_Store(); |
| 107 | + $this->identity = new Create_Identity( $this->store ); | |
| 101 | 108 | } |
| 102 | 109 | |
| 103 | 110 | /** Resolve the collection-specific writer for registry metadata. */ |
| 104 | 111 | private function writer( array $meta ) { |
| @@ -218,9 +225,14 @@ | ||
| 218 | 225 | return $mismatch; |
| 219 | 226 | } |
| 220 | 227 | } |
| 221 | 228 | if ( is_array( $hit ) && 'poison' === ( $hit['status'] ?? '' ) ) { |
| 222 | - return $this->retry_identity_stamp( $meta, $m, $hit ); | |
| 229 | + $writer = $this->writer( $meta ); | |
| 230 | + $recovered = $this->identity->recover( $meta, $m, $hit, $writer ); | |
| 231 | + if ( is_wp_error( $recovered ) ) { | |
| 232 | + return $recovered; | |
| 233 | + } | |
| 234 | + return $this->envelope_document( $this->document_for( $meta, $recovered['id'] ), $m['recordId'], $meta, $recovered['id'], $recovered['status'], $writer ); | |
| 223 | 235 | } |
| 224 | 236 | if ( is_array( $hit ) && in_array( ( $hit['status'] ?? '' ), array( 'done', 'applied' ), true ) ) { |
| 225 | 237 | if ( 'applied' === $hit['status'] && ! $this->store->finalize( $m['mutationId'], (int) $hit['remote_id'] ) ) { |
| 226 | 238 | return $this->finalize_error(); |
| @@ -404,82 +416,16 @@ | ||
| 404 | 416 | $this->store->mark_indeterminate( $m['mutationId'], 0, $response->get_status() ); |
| 405 | 417 | return new WP_Error( 'woo_rxdb_sync_create_no_id', 'Create returned no server id.', array( 'status' => 502 ) ); |
| 406 | 418 | } |
| 407 | 419 | |
| 408 | - // Poison checkpoint, UUID persistence, and finalization remain shared here. | |
| 409 | - $checkpointed = $this->store->mark_poison( $m['mutationId'], $new_id, $response->get_status() ); | |
| 410 | - $writer->persist( 'create_before_identity', $new_id, $m['payload'] ); | |
| 411 | - $identity_error = null; | |
| 412 | - if ( ! $this->store->persist_uuid( $meta['id_type'], $new_id, $m['recordId'] ) ) { | |
| 413 | - $identity_error = new WP_Error( 'woo_rxdb_sync_identity_persistence_failed', 'Unable to persist created record identity.', array( 'status' => 500 ) ); | |
| 414 | - } else { | |
| 415 | - $resolved = $this->store->resolve_id_by_uuid( $meta['id_type'], $m['recordId'], $meta ); | |
| 416 | - if ( is_wp_error( $resolved ) ) { | |
| 417 | - $identity_error = $resolved; | |
| 418 | - } elseif ( $resolved !== $new_id ) { | |
| 419 | - $identity_error = new WP_Error( 'woo_rxdb_sync_identity_persistence_failed', 'Unable to persist created record identity.', array( 'status' => 500 ) ); | |
| 420 | - } | |
| 420 | + $stamped = $this->identity->stamp( $meta, $m, $new_id, $response->get_status(), $writer ); | |
| 421 | + if ( is_wp_error( $stamped ) ) { | |
| 422 | + return $stamped; | |
| 421 | 423 | } |
| 422 | - if ( ! $checkpointed ) { | |
| 423 | - $this->store->mark_indeterminate( $m['mutationId'], $new_id, $response->get_status() ); | |
| 424 | - return $this->finalize_error(); | |
| 425 | - } | |
| 426 | - if ( $identity_error ) { | |
| 427 | - return $identity_error; | |
| 428 | - } | |
| 429 | - $writer->persist( 'create_after_identity', $new_id, $m['payload'] ); | |
| 430 | - if ( ! $this->store->finalize_poison( $m['mutationId'], $new_id ) ) { | |
| 431 | - return $this->finalize_error(); | |
| 432 | - } | |
| 433 | 424 | return $this->envelope_document( $this->document_for( $meta, $new_id ), $m['recordId'], $meta, $new_id, $response->get_status(), $writer ); |
| 434 | 425 | } |
| 435 | 426 | |
| 436 | 427 | /** |
| 437 | - * The grace comparer (#423 step 2, option `woo_rxdb_sync_legacy_revision_grace`, | |
| 438 | - * default on until retirement): on a canonical mismatch, accept a baseRevision | |
| 439 | - * that matches the CURRENT document under a PRE-CUTOVER form — | |
| 440 | - * - the legacy order sha256 (no ksort, volatiles included): same content, | |
| 441 | - * old algorithm ⇒ the precondition is genuinely current; | |
| 442 | - * - a pre-taxonomy-sort sha256 for non-orders: same content, previous | |
| 443 | - * canonicalizer ⇒ queued writes survive the revision transition; | |
| 444 | - * - a pre-1b lane synthesis (non-sha256 values the client fetchers stored | |
| 445 | - * as sync.revision before the proxy revision stamp): date_modified_gmt | |
| 446 | - * for order/post/user collections, String(id) for term collections | |
| 447 | - * (deliberately vacuous — that lane never had optimistic concurrency; | |
| 448 | - * grace honours the contract the record was written under). | |
| 449 | - * A real conflict mismatches every form → 409 exactly as before. The ack | |
| 450 | - * always returns the CANONICAL currentRevision, re-anchoring the client. | |
| 451 | - */ | |
| 452 | - private function revision_matches_with_grace( $base, string $current_revision, array $meta, int $id, array $bare, bool $allow_term_grace = true ): bool { | |
| 453 | - if ( $base === $current_revision ) { | |
| 454 | - return true; | |
| 455 | - } | |
| 456 | - if ( ! is_string( $base ) || 'yes' !== get_option( 'woocommerce_pos_sync_legacy_revision_grace', 'yes' ) ) { | |
| 457 | - return false; | |
| 458 | - } | |
| 459 | - if ( 0 === strpos( $base, 'sha256:' ) ) { | |
| 460 | - if ( 'order' === ( $meta['id_type'] ?? '' ) && $id > 0 ) { | |
| 461 | - if ( Order_Serializer::pre_augmentation_canonical_revision( $bare ) === $base | |
| 462 | - || Order_Serializer::pre_item_uuid_canonical_revision( $bare ) === $base ) { | |
| 463 | - return true; | |
| 464 | - } | |
| 465 | - $payload = ( new Order_Serializer() )->serialize_order( $id, new WP_REST_Request() ); | |
| 466 | - return Order_Serializer::legacy_revision( $payload ) === $base; | |
| 467 | - } | |
| 468 | - return Revision::pre_taxonomy_sort_revision( $bare ) === $base; | |
| 469 | - } | |
| 470 | - $id_type = $meta['id_type'] ?? ''; | |
| 471 | - if ( in_array( $id_type, array( 'order', 'post', 'user' ), true ) ) { | |
| 472 | - $date = (string) ( $bare['date_modified_gmt'] ?? '' ); | |
| 473 | - return '' !== $date && $base === $date; | |
| 474 | - } | |
| 475 | - if ( 'term' === $id_type && $allow_term_grace ) { | |
| 476 | - return (string) ( $bare['id'] ?? '' ) === $base; | |
| 477 | - } | |
| 478 | - return false; | |
| 479 | - } | |
| 480 | - | |
| 481 | - /** | |
| 482 | 428 | * The pre-CAS post-type capability gate shared by the update and delete paths. |
| 483 | 429 | * |
| 484 | 430 | * Only the WP-post-backed collections carry a Woo capability check of their own; |
| 485 | 431 | * every other collection is gated by the endpoint permission callback alone, so |
| @@ -537,9 +483,9 @@ | ||
| 537 | 483 | return $current; |
| 538 | 484 | } |
| 539 | 485 | $current_bare = is_array( $current->get_data() ) ? $current->get_data() : array(); |
| 540 | 486 | $current_revision = $this->revision_for( $meta, $id, $current_bare ); |
| 541 | - if ( ! $this->revision_matches_with_grace( $m['baseRevision'], $current_revision, $meta, $id, $current_bare ) ) { | |
| 487 | + if ( $m['baseRevision'] !== $current_revision ) { | |
| 542 | 488 | return new WP_REST_Response( |
| 543 | 489 | array( |
| 544 | 490 | 'code' => 'woo_rxdb_sync_conflict', |
| 545 | 491 | 'message' => 'baseRevision is stale.', |
| @@ -562,9 +508,9 @@ | ||
| 562 | 508 | if ( $response->get_status() >= 400 ) { |
| 563 | 509 | return new WP_REST_Response( $response->get_data(), $response->get_status() ); |
| 564 | 510 | } |
| 565 | 511 | $data = $response->get_data(); |
| 566 | - $writer->persist( 'update', $id, $m['payload'], $current_bare, is_array( $data ) ? $data : array(), $prepared['context'] ); | |
| 512 | + $writer->after_update( $id, $m['payload'], $current_bare, is_array( $data ) ? $data : array(), $prepared['context'] ); | |
| 567 | 513 | |
| 568 | 514 | $this->store->persist_uuid( $meta['id_type'], $id, $m['recordId'] ); |
| 569 | 515 | $finalized = $this->checkpoint_and_finalize( $m['mutationId'], $id, $response->get_status() ); |
| 570 | 516 | if ( is_wp_error( $finalized ) ) { |
| @@ -602,9 +548,9 @@ | ||
| 602 | 548 | return $current; |
| 603 | 549 | } |
| 604 | 550 | $current_bare = is_array( $current->get_data() ) ? $current->get_data() : array(); |
| 605 | 551 | $current_revision = $this->revision_for( $meta, $id, $current_bare ); |
| 606 | - if ( ! $this->revision_matches_with_grace( $m['baseRevision'], $current_revision, $meta, $id, $current_bare, false ) ) { | |
| 552 | + if ( $m['baseRevision'] !== $current_revision ) { | |
| 607 | 553 | return new WP_REST_Response( |
| 608 | 554 | array( |
| 609 | 555 | 'code' => 'woo_rxdb_sync_conflict', |
| 610 | 556 | 'message' => 'baseRevision is stale.', |
| @@ -636,13 +582,13 @@ | ||
| 636 | 582 | * |
| 637 | 583 | * @param int $id The order id. |
| 638 | 584 | */ |
| 639 | 585 | private function can_forward_delete( int $id ): bool { |
| 640 | - add_filter( 'woocommerce_rest_check_permissions', array( $this, 'wcpos_check_permissions' ), 10, 4 ); | |
| 586 | + Permission_Rules::install_wc_filter(); | |
| 641 | 587 | try { |
| 642 | 588 | return (bool) wc_rest_check_post_permissions( 'shop_order', 'delete', $id ); |
| 643 | 589 | } finally { |
| 644 | - remove_filter( 'woocommerce_rest_check_permissions', array( $this, 'wcpos_check_permissions' ), 10 ); | |
| 590 | + Permission_Rules::uninstall_wc_filter(); | |
| 645 | 591 | } |
| 646 | 592 | } |
| 647 | 593 | |
| 648 | 594 | private function checkpoint_and_finalize( string $mutation_id, int $remote_id, int $response_status ) { |
| @@ -684,43 +630,8 @@ | ||
| 684 | 630 | $writer = $this->writer( $meta ); |
| 685 | 631 | return $this->envelope_document( $this->document_for( $meta, $remote_id ), $expected, $meta, $remote_id, $status, $writer ); |
| 686 | 632 | } |
| 687 | 633 | |
| 688 | - private function retry_identity_stamp( array $meta, array $m, array $hit ) { | |
| 689 | - $remote_id = (int) ( $hit['remote_id'] ?? 0 ); | |
| 690 | - $record_uuid = (string) ( $hit['record_uuid'] ?? '' ); | |
| 691 | - if ( $record_uuid !== $m['recordId'] ) { | |
| 692 | - return new WP_Error( 'woo_rxdb_sync_identity_conflict', 'recordId disagrees with the stored mutation identity.', array( 'status' => 422 ) ); | |
| 693 | - } | |
| 694 | - if ( 'create' !== ( $hit['operation'] ?? '' ) || $remote_id <= 0 ) { | |
| 695 | - return new WP_Error( 'woo_rxdb_sync_identity_persistence_failed', 'Created record identity cannot be recovered safely.', array( 'status' => 500 ) ); | |
| 696 | - } | |
| 697 | - $resolved = $this->store->resolve_id_by_uuid( $meta['id_type'], $record_uuid, $meta ); | |
| 698 | - if ( is_wp_error( $resolved ) ) { | |
| 699 | - return $resolved; | |
| 700 | - } | |
| 701 | - if ( $resolved > 0 && $resolved !== $remote_id ) { | |
| 702 | - return new WP_Error( 'woo_rxdb_sync_identity_persistence_failed', 'Stored create identity points at a different record.', array( 'status' => 500 ) ); | |
| 703 | - } | |
| 704 | - if ( ! $this->store->persist_uuid( $meta['id_type'], $remote_id, $record_uuid ) ) { | |
| 705 | - return new WP_Error( 'woo_rxdb_sync_identity_persistence_failed', 'Unable to persist created record identity.', array( 'status' => 500 ) ); | |
| 706 | - } | |
| 707 | - $verified = $this->store->resolve_id_by_uuid( $meta['id_type'], $record_uuid, $meta ); | |
| 708 | - if ( is_wp_error( $verified ) ) { | |
| 709 | - return $verified; | |
| 710 | - } | |
| 711 | - if ( $verified !== $remote_id ) { | |
| 712 | - return new WP_Error( 'woo_rxdb_sync_identity_persistence_failed', 'Unable to persist created record identity.', array( 'status' => 500 ) ); | |
| 713 | - } | |
| 714 | - $writer = $this->writer( $meta ); | |
| 715 | - $writer->persist( 'create_recovery', $remote_id, $m['payload'] ); | |
| 716 | - if ( ! $this->store->finalize_poison( $m['mutationId'], $remote_id ) ) { | |
| 717 | - return $this->finalize_error(); | |
| 718 | - } | |
| 719 | - $status = isset( $hit['response_status'] ) ? (int) $hit['response_status'] : 201; | |
| 720 | - return $this->envelope_document( $this->document_for( $meta, $remote_id ), $record_uuid, $meta, $remote_id, $status, $writer ); | |
| 721 | - } | |
| 722 | - | |
| 723 | 634 | /** |
| 724 | 635 | * Validate a client-submitted `tax_ids` payload against the v1 schema. |
| 725 | 636 | * |
| 726 | 637 | * tax_ids is unknown to the stock wc/v3 controllers and is stripped before the forward, |
| @@ -788,9 +699,9 @@ | ||
| 788 | 699 | */ |
| 789 | 700 | private function dispatch_write( WP_REST_Request $request ) { |
| 790 | 701 | // Stamp here so direct callers (notably deletes) carry the scope too. |
| 791 | 702 | Store_Scope::stamp( $request ); |
| 792 | - add_filter( 'woocommerce_rest_check_permissions', array( $this, 'wcpos_check_permissions' ), 10, 4 ); | |
| 703 | + Permission_Rules::install_wc_filter(); | |
| 793 | 704 | try { |
| 794 | 705 | // Marked as OUR traffic for the duration of the forward, so a consumer |
| 795 | 706 | // keyed on store scope can act on a till write without also claiming |
| 796 | 707 | // every stock wc/v3 product write on the site (pro#425 review). |
| @@ -799,65 +710,15 @@ | ||
| 799 | 710 | return rest_do_request( $request ); |
| 800 | 711 | } |
| 801 | 712 | ); |
| 802 | 713 | } finally { |
| 803 | - remove_filter( 'woocommerce_rest_check_permissions', array( $this, 'wcpos_check_permissions' ), 10 ); | |
| 714 | + Permission_Rules::uninstall_wc_filter(); | |
| 804 | 715 | } |
| 805 | 716 | } |
| 806 | 717 | |
| 807 | - /** | |
| 808 | - * Authorize proxied catalog mutations for POS users. | |
| 809 | - * | |
| 810 | - * This filter is attached only while a sync push is forwarded to wc/v3, so | |
| 811 | - * direct WooCommerce requests keep their normal permission checks. | |
| 812 | - * | |
| 813 | - * @param bool $permission The current permission. | |
| 814 | - * @param string $context The request context. | |
| 815 | - * @param int $object_id The object ID. | |
| 816 | - * @param string $post_type The object type passed by WooCommerce. | |
| 817 | - * | |
| 818 | - * @return bool | |
| 819 | - */ | |
| 718 | + /** @deprecated Use Permission_Rules::wc_filter(). */ | |
| 820 | 719 | public function wcpos_check_permissions( $permission, $context, $object_id, $post_type ) { |
| 821 | - // Catalog and coupon WRITES require the user's real WooCommerce | |
| 822 | - // capabilities — no POS-tier widening. The cashier role is deliberately | |
| 823 | - // read-only on catalog (Activator), and a blanket grant here handed | |
| 824 | - // every POS user product deletion and coupon minting. Product decision | |
| 825 | - // 2026-08-06: strict wc/v3 parity for catalog mutations; only the | |
| 826 | - // HPOS placeholder remap below (orders) adjusts anything, and it never | |
| 827 | - // grants beyond the user's own role caps. | |
| 828 | - | |
| 829 | - // Orders: with HPOS enabled (sync off), get_post() yields shop_order_placehold | |
| 830 | - // (map_meta_cap = false, no capability_type), so WooCommerce's REST check maps | |
| 831 | - // to the generic edit_post/delete_post caps that cashier-tier roles lack — | |
| 832 | - // even though they hold the real shop_orders caps. Re-check the capability the | |
| 833 | - // mapping SHOULD have produced, mirroring V1\Orders_Controller's | |
| 834 | - // update_item_permissions_check fix. No grant beyond the user's own role caps. | |
| 835 | - if ( ! $permission && 'shop_order' === $post_type ) { | |
| 836 | - $order_caps = array( | |
| 837 | - 'read' => 'read_private_shop_orders', | |
| 838 | - 'create' => 'publish_shop_orders', | |
| 839 | - 'delete' => 'delete_shop_orders', | |
| 840 | - ); | |
| 841 | - $order_cap = $order_caps[ $context ] ?? null; | |
| 842 | - // edit and delete are ownership-sensitive: the base *_shop_orders cap only | |
| 843 | - // authorizes acting on the user's OWN orders. Touching another user's order | |
| 844 | - // additionally requires the *_others_shop_orders cap, mirroring WooCommerce's | |
| 845 | - // own meta-cap map. Without this, a cashier with delete_shop_orders (but not | |
| 846 | - // delete_others_shop_orders) could delete/void orders they do not own. | |
| 847 | - if ( \in_array( $context, array( 'edit', 'delete' ), true ) ) { | |
| 848 | - $order_post = get_post( $object_id ); | |
| 849 | - if ( $order_post ) { | |
| 850 | - $owns_order = get_current_user_id() === (int) $order_post->post_author; | |
| 851 | - $order_cap = $owns_order ? "{$context}_shop_orders" : "{$context}_others_shop_orders"; | |
| 852 | - } | |
| 853 | - } | |
| 854 | - if ( $order_cap && current_user_can( $order_cap ) ) { | |
| 855 | - $permission = true; | |
| 856 | - } | |
| 857 | - } | |
| 858 | - | |
| 859 | - return $permission; | |
| 720 | + return Permission_Rules::wc_filter( $permission, $context, $object_id, $post_type, 'writes' ); | |
| 860 | 721 | } |
| 861 | 722 | |
| 862 | 723 | /** |
| 863 | 724 | * Read this collection's document for one record, through its writer. |
| @@ -951,13 +812,32 @@ | ||
| 951 | 812 | } |
| 952 | 813 | |
| 953 | 814 | private function revision_for( array $meta, int $id, array $bare ): string { |
| 954 | 815 | if ( 'product_variation' === ( $meta['post_type'] ?? '' ) ) { |
| 955 | - // The targeted variation pull stores exactly this source as | |
| 956 | - // sync.revision (apps/web/src/db/variationIncludePull.ts): modified | |
| 957 | - // timestamp, falling back to the wrapper id. | |
| 816 | + /* | |
| 817 | + * A variation's revision is its `date_modified_gmt`, deliberately: the client's targeted | |
| 818 | + * pull synthesizes exactly that as `sync.revision`, so both sides agree without the | |
| 819 | + * variations lane needing a stamped `_rxdb_revision`. | |
| 820 | + * | |
| 821 | + * Read the date from WHEREVER it is — nested under `payload` in today's | |
| 822 | + * `{ id, parent_id, payload }` wrapper, or top level once that wrapper is dropped. | |
| 823 | + * | |
| 824 | + * This used to read `$bare['payload']['date_modified_gmt']` only, with `$bare['id']` as | |
| 825 | + * the fallback. Against a FLAT document that silently degrades to the variation's own | |
| 826 | + * ID — a value that never changes again. The failure would be total and invisible: | |
| 827 | + * the ack would hand the client the id as `currentRevision`, and from then on every | |
| 828 | + * stale baseRevision would equal every recomputed one — the strict revision | |
| 829 | + * comparison would pass every queued write. Two tills editing the same | |
| 830 | + * variation hours apart would both pass the precondition; the per-record lock would | |
| 831 | + * serialize them, so there would be no error — just a lost update, every time. | |
| 832 | + * | |
| 833 | + * The `$bare['id']` fallback is kept ONLY for a document carrying no date at all, and is | |
| 834 | + * now unreachable for any real variation serialization. | |
| 835 | + */ | |
| 958 | 836 | $payload = isset( $bare['payload'] ) && is_array( $bare['payload'] ) ? $bare['payload'] : array(); |
| 959 | - return (string) ( $payload['date_modified_gmt'] ?? $bare['id'] ?? $id ); | |
| 837 | + $date = $payload['date_modified_gmt'] ?? $bare['date_modified_gmt'] ?? null; | |
| 838 | + | |
| 839 | + return (string) ( $date ?? $bare['id'] ?? $id ); | |
| 960 | 840 | } |
| 961 | 841 | if ( 'order' === ( $meta['id_type'] ?? '' ) && $id > 0 ) { |
| 962 | 842 | return Order_Serializer::canonical_revision( $bare ); |
| 963 | 843 | } |