From 5fa4d60e31f4686b5d99527f9e0e71f4bbfb1ec1 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 24 Jun 2026 20:43:33 -0700 Subject: [PATCH 1/7] Comments: Make REST comment moderation comment-type aware. `WP_REST_Comments_Controller::check_edit_permission()` short-circuited on the global `moderate_comments` primitive, so a moderator of a custom comment type could not edit or delete comments of that type via REST, while a global moderator could act on every type regardless of its capability model. Route the moderator shortcut through the `moderate_comment` meta capability instead. For comment types using the default capability model this resolves to `moderate_comments` (behavior unchanged), while a type with its own capabilities is gated by its own moderation primitive. Apply the same change to the orphaned-comment branch of `check_read_permission()`. This consumes the meta capabilities added in the previous commit; it makes no change for the built-in comment, pingback, trackback, or note types. See #35214. --- .../endpoints/class-wp-rest-comments-controller.php | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php index f462928847c77..b1038d50539c3 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php @@ -1932,7 +1932,7 @@ protected function check_read_permission( $comment, $request ) { return false; } - if ( empty( $comment->comment_post_ID ) && ! current_user_can( 'moderate_comments' ) ) { + if ( empty( $comment->comment_post_ID ) && ! current_user_can( 'moderate_comment', $comment->comment_ID ) ) { return false; } @@ -1956,7 +1956,13 @@ protected function check_edit_permission( $comment ) { return false; } - if ( current_user_can( 'moderate_comments' ) ) { + /* + * Use the per-comment `moderate_comment` meta capability rather than the + * global `moderate_comments` primitive. For comment types using the default + * capability model this resolves to `moderate_comments` (unchanged), while a + * type with its own capabilities is gated by its own moderation primitive. + */ + if ( current_user_can( 'moderate_comment', $comment->comment_ID ) ) { return true; } From 04c12eca91d592feec727f01ba96150087b18872 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 24 Jun 2026 20:43:33 -0700 Subject: [PATCH 2/7] Comments: Add REST tests for comment-type-aware moderation. Dispatch real update and delete requests through the comments controller to prove `check_edit_permission()` honors per-type capabilities: a moderator (or editor) of an independent `review` type can edit and delete review comments, a global `moderate_comments` moderator cannot touch them, and the default and built-in ping types behave exactly as before. See #35214. --- .../rest-api/rest-comment-type-moderation.php | 228 ++++++++++++++++++ 1 file changed, 228 insertions(+) create mode 100644 tests/phpunit/tests/rest-api/rest-comment-type-moderation.php diff --git a/tests/phpunit/tests/rest-api/rest-comment-type-moderation.php b/tests/phpunit/tests/rest-api/rest-comment-type-moderation.php new file mode 100644 index 0000000000000..b63ae32a73a9c --- /dev/null +++ b/tests/phpunit/tests/rest-api/rest-comment-type-moderation.php @@ -0,0 +1,228 @@ +post->create(); + + self::$global_moderator_id = $factory->user->create( array( 'role' => 'subscriber' ) ); + get_userdata( self::$global_moderator_id )->add_cap( 'moderate_comments' ); + + self::$review_moderator_id = $factory->user->create( array( 'role' => 'subscriber' ) ); + get_userdata( self::$review_moderator_id )->add_cap( 'moderate_reviews' ); + + self::$review_editor_id = $factory->user->create( array( 'role' => 'subscriber' ) ); + get_userdata( self::$review_editor_id )->add_cap( 'edit_others_reviews' ); + } + + public function set_up() { + parent::set_up(); + + // A comment type with an independent capability model. + register_comment_type( 'review', array( 'capability_type' => 'review' ) ); + } + + public function tear_down() { + unregister_comment_type( 'review' ); + + parent::tear_down(); + } + + /** + * Creates a comment of the given type on the shared post. + * + * @param string $comment_type Comment type slug. + * @return int The new comment ID. + */ + private function make_comment( $comment_type ) { + return self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'comment_type' => $comment_type, + 'comment_approved' => '1', + ) + ); + } + + /** + * Dispatches a REST request to update a comment's content. + * + * @param int $comment_id Comment to update. + * @return WP_REST_Response + */ + private function dispatch_update( $comment_id ) { + $request = new WP_REST_Request( 'POST', '/wp/v2/comments/' . $comment_id ); + $request->set_param( 'content', 'Updated content' ); + + return rest_get_server()->dispatch( $request ); + } + + /** + * Dispatches a REST request to force-delete a comment. + * + * @param int $comment_id Comment to delete. + * @return WP_REST_Response + */ + private function dispatch_delete( $comment_id ) { + $request = new WP_REST_Request( 'DELETE', '/wp/v2/comments/' . $comment_id ); + $request->set_param( 'force', true ); + + return rest_get_server()->dispatch( $request ); + } + + /* + * Independent capability model. + */ + + /** + * @ticket 35214 + */ + public function test_review_moderator_can_update_review_comment() { + wp_set_current_user( self::$review_moderator_id ); + + $response = $this->dispatch_update( $this->make_comment( 'review' ) ); + + $this->assertSame( 200, $response->get_status() ); + } + + /** + * @ticket 35214 + */ + public function test_review_moderator_can_delete_review_comment() { + wp_set_current_user( self::$review_moderator_id ); + + $response = $this->dispatch_delete( $this->make_comment( 'review' ) ); + + $this->assertSame( 200, $response->get_status() ); + } + + /** + * The type's `edit_others_reviews` primitive also grants moderation through the + * `edit_comment` fallback in `check_edit_permission()`. + * + * @ticket 35214 + */ + public function test_review_editor_can_update_review_comment() { + wp_set_current_user( self::$review_editor_id ); + + $response = $this->dispatch_update( $this->make_comment( 'review' ) ); + + $this->assertSame( 200, $response->get_status() ); + } + + /** + * A global `moderate_comments` moderator has no power over an independent type. + * + * @ticket 35214 + */ + public function test_global_moderator_cannot_update_review_comment() { + wp_set_current_user( self::$global_moderator_id ); + + $response = $this->dispatch_update( $this->make_comment( 'review' ) ); + + $this->assertSame( 403, $response->get_status() ); + } + + /** + * @ticket 35214 + */ + public function test_global_moderator_cannot_delete_review_comment() { + wp_set_current_user( self::$global_moderator_id ); + + $response = $this->dispatch_delete( $this->make_comment( 'review' ) ); + + $this->assertSame( 403, $response->get_status() ); + } + + /** + * A review moderator has no power over a default-model comment. + * + * @ticket 35214 + */ + public function test_review_moderator_cannot_update_default_comment() { + wp_set_current_user( self::$review_moderator_id ); + + $response = $this->dispatch_update( $this->make_comment( 'comment' ) ); + + $this->assertSame( 403, $response->get_status() ); + } + + /* + * Default capability model: unchanged from historical behavior. + */ + + /** + * @ticket 35214 + */ + public function test_global_moderator_can_update_default_comment() { + wp_set_current_user( self::$global_moderator_id ); + + $response = $this->dispatch_update( $this->make_comment( 'comment' ) ); + + $this->assertSame( 200, $response->get_status() ); + } + + /** + * @ticket 35214 + */ + public function test_global_moderator_can_delete_default_comment() { + wp_set_current_user( self::$global_moderator_id ); + + $response = $this->dispatch_delete( $this->make_comment( 'comment' ) ); + + $this->assertSame( 200, $response->get_status() ); + } + + /** + * The built-in pingback type uses the default model and is unaffected. + * + * @ticket 35214 + */ + public function test_global_moderator_can_update_builtin_ping_comment() { + wp_set_current_user( self::$global_moderator_id ); + + $response = $this->dispatch_update( $this->make_comment( 'pingback' ) ); + + $this->assertSame( 200, $response->get_status() ); + } +} From bb3cec652629790d649bd6ad077955b9f2a86ab2 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Sat, 11 Jul 2026 10:49:58 -0700 Subject: [PATCH 3/7] REST API: Route the remaining per-comment moderation gate through comment types. get_item_permissions_check() still gated edit-context reads of non-note comments on the global 'moderate_comments' primitive. After the update and delete paths moved to the per-comment 'moderate_comment' meta capability, a type moderator could update or delete a comment (with the response already returning edit-context data) yet receive 403 reading the same comment with ?context=edit. Route the non-note branch through array( 'moderate_comment', $comment->comment_ID ) to match. Also mark every comment gate that intentionally stays on the global primitive (collection-level and creation-time gates, including the documented ?post=0 divergence), add the missing @since entries on check_edit_permission() and check_read_permission(), and expand the tests: the unregistered-type back-compat pin (a global moderator keeps full control of never-registered types), legacy empty-string types, single-item edit-context reads per model, orphaned-comment reads under both models, and the ?post=0 collection matrix. Denials now assert error codes via assertErrorResponse(). --- .../class-wp-rest-comments-controller.php | 30 ++- .../rest-api/rest-comment-type-moderation.php | 215 ++++++++++++++++-- 2 files changed, 229 insertions(+), 16 deletions(-) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php index b1038d50539c3..ee36774df133d 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php @@ -139,6 +139,14 @@ public function get_items_permissions_check( $request ) { array( 'status' => rest_authorization_required_code() ) ); } elseif ( 0 === $post_id && ! current_user_can( 'moderate_comments' ) ) { + /* + * Intentionally the global primitive: this gates the whole ?post=0 + * collection, which can mix comment types, before any individual + * comment is known. Known divergence: a global moderator sees + * independent-type orphans filtered out of the list (empty 200) + * while a type moderator gets 403 here. A per-type collection + * treatment belongs with a future `type` parameter rework. + */ return new WP_Error( 'rest_cannot_read', __( 'Sorry, you are not allowed to read comments without a post.' ), @@ -190,6 +198,8 @@ public function get_items_permissions_check( $request ) { } } } elseif ( $is_edit_context && ! current_user_can( 'moderate_comments' ) ) { + // Intentionally the global primitive: gates an edit-context collection + // that can mix comment types, before any individual comment is known. return new WP_Error( 'rest_forbidden_context', __( 'Sorry, you are not allowed to edit comments.' ), @@ -437,8 +447,13 @@ public function get_item_permissions_check( $request ) { return $comment; } - // Re-map edit context capabilities when requesting `note` type. - $edit_cap = 'note' === $comment->comment_type ? array( 'edit_comment', $comment->comment_ID ) : array( 'moderate_comments' ); + /* + * Re-map edit context capabilities per comment type: `note` comments are + * gated by `edit_comment`, all others by the per-comment `moderate_comment` + * meta capability. This matches the update and delete paths, whose responses + * already return edit-context data. + */ + $edit_cap = 'note' === $comment->comment_type ? array( 'edit_comment', $comment->comment_ID ) : array( 'moderate_comment', $comment->comment_ID ); if ( ! empty( $request['context'] ) && 'edit' === $request['context'] && ! current_user_can( ...$edit_cap ) ) { return new WP_Error( 'rest_forbidden_context', @@ -539,7 +554,12 @@ public function create_item_permissions_check( $request ) { } } - // Limit who can set comment `author`, `author_ip` or `status` to anything other than the default. + /* + * Limit who can set comment `author`, `author_ip` or `status` to anything + * other than the default. Intentionally the global primitive: these gates + * run at creation time, and honoring the (known) type's own moderation + * primitive here is future work alongside the other creation-time gates. + */ if ( isset( $request['author'] ) && get_current_user_id() !== $request['author'] && ! current_user_can( 'moderate_comments' ) ) { return new WP_Error( 'rest_comment_invalid_author', @@ -1913,6 +1933,8 @@ protected function check_read_post_permission( $post, $request ) { * Checks if the comment can be read. * * @since 4.7.0 + * @since 7.1.0 Orphaned comments are gated by the per-comment `moderate_comment` + * meta capability instead of the global `moderate_comments` primitive. * * @param WP_Comment $comment Comment object. * @param WP_REST_Request $request Request data to check. @@ -1947,6 +1969,8 @@ protected function check_read_permission( $comment, $request ) { * Checks if a comment can be edited or deleted. * * @since 4.7.0 + * @since 7.1.0 Uses the per-comment `moderate_comment` meta capability instead + * of the global `moderate_comments` primitive. * * @param WP_Comment $comment Comment object. * @return bool Whether the comment can be edited or deleted. diff --git a/tests/phpunit/tests/rest-api/rest-comment-type-moderation.php b/tests/phpunit/tests/rest-api/rest-comment-type-moderation.php index b63ae32a73a9c..882d55080a0f6 100644 --- a/tests/phpunit/tests/rest-api/rest-comment-type-moderation.php +++ b/tests/phpunit/tests/rest-api/rest-comment-type-moderation.php @@ -11,8 +11,10 @@ * @group capabilities * * @covers WP_REST_Comments_Controller::check_edit_permission + * @covers WP_REST_Comments_Controller::check_read_permission + * @covers WP_REST_Comments_Controller::get_item_permissions_check */ -class Tests_REST_Comment_Type_Moderation extends WP_UnitTestCase { +class Tests_REST_Comment_Type_Moderation extends WP_Test_REST_TestCase { /** * Post the comments are attached to. @@ -21,6 +23,13 @@ class Tests_REST_Comment_Type_Moderation extends WP_UnitTestCase { */ protected static $post_id; + /** + * Administrator user ID (moderate_comments plus edit_posts). + * + * @var int + */ + protected static $admin_id; + /** * User with the global `moderate_comments` primitive only. * @@ -43,7 +52,8 @@ class Tests_REST_Comment_Type_Moderation extends WP_UnitTestCase { protected static $review_editor_id; public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) { - self::$post_id = $factory->post->create(); + self::$post_id = $factory->post->create(); + self::$admin_id = $factory->user->create( array( 'role' => 'administrator' ) ); self::$global_moderator_id = $factory->user->create( array( 'role' => 'subscriber' ) ); get_userdata( self::$global_moderator_id )->add_cap( 'moderate_comments' ); @@ -62,22 +72,18 @@ public function set_up() { register_comment_type( 'review', array( 'capability_type' => 'review' ) ); } - public function tear_down() { - unregister_comment_type( 'review' ); - - parent::tear_down(); - } - /** * Creates a comment of the given type on the shared post. * * @param string $comment_type Comment type slug. + * @param int $post_id Optional. Post to attach the comment to; 0 for an + * orphaned comment. Default the shared post. * @return int The new comment ID. */ - private function make_comment( $comment_type ) { + private function make_comment( $comment_type, $post_id = null ) { return self::factory()->comment->create( array( - 'comment_post_ID' => self::$post_id, + 'comment_post_ID' => null === $post_id ? self::$post_id : $post_id, 'comment_type' => $comment_type, 'comment_approved' => '1', ) @@ -110,6 +116,20 @@ private function dispatch_delete( $comment_id ) { return rest_get_server()->dispatch( $request ); } + /** + * Dispatches a REST request to read a single comment. + * + * @param int $comment_id Comment to read. + * @param string $context Optional. Request context. Default 'view'. + * @return WP_REST_Response + */ + private function dispatch_get( $comment_id, $context = 'view' ) { + $request = new WP_REST_Request( 'GET', '/wp/v2/comments/' . $comment_id ); + $request->set_param( 'context', $context ); + + return rest_get_server()->dispatch( $request ); + } + /* * Independent capability model. */ @@ -160,7 +180,7 @@ public function test_global_moderator_cannot_update_review_comment() { $response = $this->dispatch_update( $this->make_comment( 'review' ) ); - $this->assertSame( 403, $response->get_status() ); + $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); } /** @@ -171,7 +191,7 @@ public function test_global_moderator_cannot_delete_review_comment() { $response = $this->dispatch_delete( $this->make_comment( 'review' ) ); - $this->assertSame( 403, $response->get_status() ); + $this->assertErrorResponse( 'rest_cannot_delete', $response, 403 ); } /** @@ -184,7 +204,7 @@ public function test_review_moderator_cannot_update_default_comment() { $response = $this->dispatch_update( $this->make_comment( 'comment' ) ); - $this->assertSame( 403, $response->get_status() ); + $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); } /* @@ -225,4 +245,173 @@ public function test_global_moderator_can_update_builtin_ping_comment() { $this->assertSame( 200, $response->get_status() ); } + + /** + * An UNREGISTERED comment type stays fully moderatable by a global moderator. + * + * This pins the back-compat fallback everything relies on: comments of types + * that were never registered (e.g. stored by plugins before registration + * existed, or after their plugin is deactivated) resolve to a null type + * object and behave exactly like default comments. + * + * @ticket 35214 + */ + public function test_global_moderator_retains_control_over_unregistered_type() { + wp_set_current_user( self::$global_moderator_id ); + + $webmention_id = $this->make_comment( 'webmention' ); + + $response = $this->dispatch_update( $webmention_id ); + $this->assertSame( 200, $response->get_status(), 'A global moderator should be able to update an unregistered-type comment.' ); + + $response = $this->dispatch_delete( $webmention_id ); + $this->assertSame( 200, $response->get_status(), 'A global moderator should be able to delete an unregistered-type comment.' ); + } + + /** + * A comment stored with the legacy empty string type behaves like a default comment. + * + * @ticket 35214 + */ + public function test_global_moderator_can_update_legacy_empty_type_comment() { + wp_set_current_user( self::$global_moderator_id ); + + $response = $this->dispatch_update( $this->make_comment( '' ) ); + + $this->assertSame( 200, $response->get_status() ); + } + + /* + * Single-item edit-context reads (get_item_permissions_check()): the read gate + * matches the update/delete gates, whose responses already return edit data. + */ + + /** + * @ticket 35214 + */ + public function test_review_moderator_can_read_review_comment_in_edit_context() { + wp_set_current_user( self::$review_moderator_id ); + + $response = $this->dispatch_get( $this->make_comment( 'review' ), 'edit' ); + + $this->assertSame( 200, $response->get_status() ); + } + + /** + * @ticket 35214 + */ + public function test_global_moderator_cannot_read_review_comment_in_edit_context() { + wp_set_current_user( self::$global_moderator_id ); + + $response = $this->dispatch_get( $this->make_comment( 'review' ), 'edit' ); + + $this->assertErrorResponse( 'rest_forbidden_context', $response, 403 ); + } + + /** + * @ticket 35214 + */ + public function test_global_moderator_can_read_default_comment_in_edit_context() { + wp_set_current_user( self::$global_moderator_id ); + + $response = $this->dispatch_get( $this->make_comment( 'comment' ), 'edit' ); + + $this->assertSame( 200, $response->get_status() ); + } + + /* + * Orphaned comments (comment_post_ID = 0) through check_read_permission(). + */ + + /** + * Reading an orphaned default comment requires passing both the orphan gate + * (moderate_comment) and the final edit_comment check, which for orphans + * falls back to edit_posts - so administrators pass and bare moderators do + * not, exactly as before this change. + * + * @ticket 35214 + */ + public function test_admin_can_read_orphaned_default_comment() { + wp_set_current_user( self::$admin_id ); + + $response = $this->dispatch_get( $this->make_comment( 'comment', 0 ) ); + + $this->assertSame( 200, $response->get_status() ); + } + + /** + * @ticket 35214 + */ + public function test_subscriber_cannot_read_orphaned_default_comment() { + wp_set_current_user( self::factory()->user->create( array( 'role' => 'subscriber' ) ) ); + + $response = $this->dispatch_get( $this->make_comment( 'comment', 0 ) ); + + $this->assertErrorResponse( 'rest_cannot_read', $response, 403 ); + } + + /** + * Reading an orphaned independent-type comment requires the type's own + * capabilities on both checks: its moderation primitive for the orphan gate + * and its edit primitives for the final edit_comment check. + * + * @ticket 35214 + */ + public function test_review_moderator_with_edit_cap_can_read_orphaned_review_comment() { + $full_review_moderator = self::factory()->user->create_and_get( array( 'role' => 'subscriber' ) ); + $full_review_moderator->add_cap( 'moderate_reviews' ); + $full_review_moderator->add_cap( 'edit_others_reviews' ); + + wp_set_current_user( $full_review_moderator->ID ); + + $response = $this->dispatch_get( $this->make_comment( 'review', 0 ) ); + + $this->assertSame( 200, $response->get_status() ); + } + + /** + * Even an administrator (global moderate_comments and edit_posts) cannot + * read an orphaned comment of an independent type. + * + * @ticket 35214 + */ + public function test_admin_cannot_read_orphaned_review_comment() { + wp_set_current_user( self::$admin_id ); + + $response = $this->dispatch_get( $this->make_comment( 'review', 0 ) ); + + $this->assertErrorResponse( 'rest_cannot_read', $response, 403 ); + } + + /** + * The `?post=0` collection gate stays on the global primitive: a global + * moderator gets the list with independent-type orphans filtered out, while + * a type moderator is rejected at the gate. This pins the known, documented + * divergence until a per-type collection treatment exists. + * + * @ticket 35214 + */ + public function test_orphan_collection_gate_uses_global_primitive() { + $default_orphan = $this->make_comment( 'comment', 0 ); + $review_orphan = $this->make_comment( 'review', 0 ); + + wp_set_current_user( self::$admin_id ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/comments' ); + $request->set_param( 'post', 0 ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + $ids = wp_list_pluck( $response->get_data(), 'id' ); + $this->assertContains( $default_orphan, $ids, 'The default-type orphan should be listed for an administrator.' ); + $this->assertNotContains( $review_orphan, $ids, 'The independent-type orphan should be filtered out for an administrator.' ); + + wp_set_current_user( self::$review_moderator_id ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/comments' ); + $request->set_param( 'post', 0 ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_cannot_read', $response, 403 ); + } } From d37f6673ebf0d759b085d79f2e298b600f50ebc1 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Sun, 8 Nov 2026 09:24:51 -0500 Subject: [PATCH 4/7] REST API: Tighten comment type moderation inline docs. The moderation gates landed with `@since 7.1.0` tags, but this ships in 7.2.0, and the inline comments narrated design history that belongs in the PR. Shortens them, corrects the version tags, documents the edit-context change on get_item_permissions_check(), and restores the unrelated note comment trunk wording. No behavior change. See #35214. --- .../class-wp-rest-comments-controller.php | 40 +++++-------------- 1 file changed, 11 insertions(+), 29 deletions(-) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php index 0bfd2ec4e7723..b289cfd0701ea 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php @@ -168,12 +168,9 @@ public function get_items_permissions_check( $request ) { ); } elseif ( 0 === $post_id && ! current_user_can( 'moderate_comments' ) ) { /* - * Intentionally the global primitive: this gates the whole ?post=0 - * collection, which can mix comment types, before any individual - * comment is known. Known divergence: a global moderator sees - * independent-type orphans filtered out of the list (empty 200) - * while a type moderator gets 403 here. A per-type collection - * treatment belongs with a future `type` parameter rework. + * The global primitive gates the ?post=0 collection, which can mix + * comment types. Orphans of an independent type are filtered out + * per comment, so a type's own moderators are rejected here. */ return new WP_Error( 'rest_cannot_read', @@ -226,8 +223,7 @@ public function get_items_permissions_check( $request ) { } } } elseif ( $is_edit_context && ! current_user_can( 'moderate_comments' ) ) { - // Intentionally the global primitive: gates an edit-context collection - // that can mix comment types, before any individual comment is known. + // The global primitive gates an edit-context collection that can mix comment types. return new WP_Error( 'rest_forbidden_context', __( 'Sorry, you are not allowed to edit comments.' ), @@ -483,6 +479,7 @@ protected function get_comment( $id ) { * Checks if a given request has access to read the comment. * * @since 4.7.0 + * @since 7.2.0 The edit context is gated by the `moderate_comment` meta capability. * * @param WP_REST_Request $request Full details about the request. * @return true|WP_Error True if the request has read access for the item, error object otherwise. @@ -493,11 +490,7 @@ public function get_item_permissions_check( $request ) { return $comment; } - /* - * Re-map edit context capabilities per comment type: `note` and `reaction` - * comments are gated by `edit_comment`, all others by the per-comment - * `moderate_comment` meta capability, matching the update and delete paths. - */ + // Match check_edit_permission(): notes and reactions need `edit_comment`, other types `moderate_comment`. $edit_cap = in_array( $comment->comment_type, array( 'note', 'reaction' ), true ) ? array( 'edit_comment', $comment->comment_ID ) : array( 'moderate_comment', $comment->comment_ID ); if ( ! empty( $request['context'] ) && 'edit' === $request['context'] && ! current_user_can( ...$edit_cap ) ) { return new WP_Error( @@ -599,12 +592,7 @@ public function create_item_permissions_check( $request ) { } } - /* - * Limit who can set comment `author`, `author_ip` or `status` to anything - * other than the default. Intentionally the global primitive: these gates - * run at creation time, and honoring the (known) type's own moderation - * primitive here is future work alongside the other creation-time gates. - */ + // Limit who can set comment `author`, `author_ip` or `status` to anything other than the default. if ( isset( $request['author'] ) && get_current_user_id() !== $request['author'] && ! current_user_can( 'moderate_comments' ) ) { return new WP_Error( 'rest_comment_invalid_author', @@ -2420,8 +2408,7 @@ protected function prefetch_reaction_summaries( $note_ids ) { * Checks if the comment can be read. * * @since 4.7.0 - * @since 7.1.0 Orphaned comments are gated by the per-comment `moderate_comment` - * meta capability instead of the global `moderate_comments` primitive. + * @since 7.2.0 Orphaned comments are gated by the `moderate_comment` meta capability. * * @param WP_Comment $comment Comment object. * @param WP_REST_Request $request Request data to check. @@ -2456,8 +2443,7 @@ protected function check_read_permission( $comment, $request ) { * Checks if a comment can be edited or deleted. * * @since 4.7.0 - * @since 7.1.0 Uses the per-comment `moderate_comment` meta capability instead - * of the global `moderate_comments` primitive. + * @since 7.2.0 Moderators are checked with the `moderate_comment` meta capability. * * @param WP_Comment $comment Comment object. * @return bool Whether the comment can be edited or deleted. @@ -2469,17 +2455,13 @@ protected function check_edit_permission( $comment ) { /* * Notes and their reactions are editorial content, so require edit access - * to the note's post rather than moderation rights. + * to the note's post rather than the moderate_comments capability. */ if ( in_array( strtolower( $comment->comment_type ), array( 'note', 'reaction' ), true ) ) { return current_user_can( 'edit_comment', $comment->comment_ID ); } - /* - * The per-comment `moderate_comment` meta capability resolves to - * `moderate_comments` for the default capability model, and to a type's - * own moderation primitive when it registers independent capabilities. - */ + // Resolves to the type's own moderation primitive, or `moderate_comments` for the default model. if ( current_user_can( 'moderate_comment', $comment->comment_ID ) ) { return true; } From bfeae3dfe47638c7762afcf93cfcebd6b8841ecb Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Sun, 8 Nov 2026 09:24:52 -0500 Subject: [PATCH 5/7] Tests: Pin note and reaction gates in REST comment type moderation. Trunk added the `reaction` type, gated like `note` by `edit_comment` rather than moderation. The merge kept that, but nothing failed if reactions were routed through `moderate_comment`, which would let a bare global moderator delete or read reactions in the edit context. Adds note and reaction cases for both paths, and native types so the file passes PHPStan level 10. See #35214. --- .../rest-api/rest-comment-type-moderation.php | 185 ++++++++++++++---- 1 file changed, 147 insertions(+), 38 deletions(-) diff --git a/tests/phpunit/tests/rest-api/rest-comment-type-moderation.php b/tests/phpunit/tests/rest-api/rest-comment-type-moderation.php index 882d55080a0f6..431c240661ea1 100644 --- a/tests/phpunit/tests/rest-api/rest-comment-type-moderation.php +++ b/tests/phpunit/tests/rest-api/rest-comment-type-moderation.php @@ -21,51 +21,54 @@ class Tests_REST_Comment_Type_Moderation extends WP_Test_REST_TestCase { * * @var int */ - protected static $post_id; + protected static int $post_id; /** * Administrator user ID (moderate_comments plus edit_posts). * * @var int */ - protected static $admin_id; + protected static int $admin_id; /** * User with the global `moderate_comments` primitive only. * * @var int */ - protected static $global_moderator_id; + protected static int $global_moderator_id; /** * User with the `review` type's `moderate_reviews` primitive only. * * @var int */ - protected static $review_moderator_id; + protected static int $review_moderator_id; /** * User with the `review` type's `edit_others_reviews` primitive only. * * @var int */ - protected static $review_editor_id; + protected static int $review_editor_id; - public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) { + public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ): void { self::$post_id = $factory->post->create(); self::$admin_id = $factory->user->create( array( 'role' => 'administrator' ) ); - self::$global_moderator_id = $factory->user->create( array( 'role' => 'subscriber' ) ); - get_userdata( self::$global_moderator_id )->add_cap( 'moderate_comments' ); + $user = $factory->user->create_and_get( array( 'role' => 'subscriber' ) ); + $user->add_cap( 'moderate_comments' ); + self::$global_moderator_id = $user->ID; - self::$review_moderator_id = $factory->user->create( array( 'role' => 'subscriber' ) ); - get_userdata( self::$review_moderator_id )->add_cap( 'moderate_reviews' ); + $user = $factory->user->create_and_get( array( 'role' => 'subscriber' ) ); + $user->add_cap( 'moderate_reviews' ); + self::$review_moderator_id = $user->ID; - self::$review_editor_id = $factory->user->create( array( 'role' => 'subscriber' ) ); - get_userdata( self::$review_editor_id )->add_cap( 'edit_others_reviews' ); + $user = $factory->user->create_and_get( array( 'role' => 'subscriber' ) ); + $user->add_cap( 'edit_others_reviews' ); + self::$review_editor_id = $user->ID; } - public function set_up() { + public function set_up(): void { parent::set_up(); // A comment type with an independent capability model. @@ -77,10 +80,10 @@ public function set_up() { * * @param string $comment_type Comment type slug. * @param int $post_id Optional. Post to attach the comment to; 0 for an - * orphaned comment. Default the shared post. + * orphaned comment. Default null (the shared post). * @return int The new comment ID. */ - private function make_comment( $comment_type, $post_id = null ) { + private function make_comment( string $comment_type, ?int $post_id = null ): int { return self::factory()->comment->create( array( 'comment_post_ID' => null === $post_id ? self::$post_id : $post_id, @@ -96,7 +99,7 @@ private function make_comment( $comment_type, $post_id = null ) { * @param int $comment_id Comment to update. * @return WP_REST_Response */ - private function dispatch_update( $comment_id ) { + private function dispatch_update( int $comment_id ): WP_REST_Response { $request = new WP_REST_Request( 'POST', '/wp/v2/comments/' . $comment_id ); $request->set_param( 'content', 'Updated content' ); @@ -109,7 +112,7 @@ private function dispatch_update( $comment_id ) { * @param int $comment_id Comment to delete. * @return WP_REST_Response */ - private function dispatch_delete( $comment_id ) { + private function dispatch_delete( int $comment_id ): WP_REST_Response { $request = new WP_REST_Request( 'DELETE', '/wp/v2/comments/' . $comment_id ); $request->set_param( 'force', true ); @@ -123,7 +126,7 @@ private function dispatch_delete( $comment_id ) { * @param string $context Optional. Request context. Default 'view'. * @return WP_REST_Response */ - private function dispatch_get( $comment_id, $context = 'view' ) { + private function dispatch_get( int $comment_id, string $context = 'view' ): WP_REST_Response { $request = new WP_REST_Request( 'GET', '/wp/v2/comments/' . $comment_id ); $request->set_param( 'context', $context ); @@ -137,7 +140,7 @@ private function dispatch_get( $comment_id, $context = 'view' ) { /** * @ticket 35214 */ - public function test_review_moderator_can_update_review_comment() { + public function test_review_moderator_can_update_review_comment(): void { wp_set_current_user( self::$review_moderator_id ); $response = $this->dispatch_update( $this->make_comment( 'review' ) ); @@ -148,7 +151,7 @@ public function test_review_moderator_can_update_review_comment() { /** * @ticket 35214 */ - public function test_review_moderator_can_delete_review_comment() { + public function test_review_moderator_can_delete_review_comment(): void { wp_set_current_user( self::$review_moderator_id ); $response = $this->dispatch_delete( $this->make_comment( 'review' ) ); @@ -162,7 +165,7 @@ public function test_review_moderator_can_delete_review_comment() { * * @ticket 35214 */ - public function test_review_editor_can_update_review_comment() { + public function test_review_editor_can_update_review_comment(): void { wp_set_current_user( self::$review_editor_id ); $response = $this->dispatch_update( $this->make_comment( 'review' ) ); @@ -175,7 +178,7 @@ public function test_review_editor_can_update_review_comment() { * * @ticket 35214 */ - public function test_global_moderator_cannot_update_review_comment() { + public function test_global_moderator_cannot_update_review_comment(): void { wp_set_current_user( self::$global_moderator_id ); $response = $this->dispatch_update( $this->make_comment( 'review' ) ); @@ -186,7 +189,7 @@ public function test_global_moderator_cannot_update_review_comment() { /** * @ticket 35214 */ - public function test_global_moderator_cannot_delete_review_comment() { + public function test_global_moderator_cannot_delete_review_comment(): void { wp_set_current_user( self::$global_moderator_id ); $response = $this->dispatch_delete( $this->make_comment( 'review' ) ); @@ -199,7 +202,7 @@ public function test_global_moderator_cannot_delete_review_comment() { * * @ticket 35214 */ - public function test_review_moderator_cannot_update_default_comment() { + public function test_review_moderator_cannot_update_default_comment(): void { wp_set_current_user( self::$review_moderator_id ); $response = $this->dispatch_update( $this->make_comment( 'comment' ) ); @@ -214,7 +217,7 @@ public function test_review_moderator_cannot_update_default_comment() { /** * @ticket 35214 */ - public function test_global_moderator_can_update_default_comment() { + public function test_global_moderator_can_update_default_comment(): void { wp_set_current_user( self::$global_moderator_id ); $response = $this->dispatch_update( $this->make_comment( 'comment' ) ); @@ -225,7 +228,7 @@ public function test_global_moderator_can_update_default_comment() { /** * @ticket 35214 */ - public function test_global_moderator_can_delete_default_comment() { + public function test_global_moderator_can_delete_default_comment(): void { wp_set_current_user( self::$global_moderator_id ); $response = $this->dispatch_delete( $this->make_comment( 'comment' ) ); @@ -238,7 +241,7 @@ public function test_global_moderator_can_delete_default_comment() { * * @ticket 35214 */ - public function test_global_moderator_can_update_builtin_ping_comment() { + public function test_global_moderator_can_update_builtin_ping_comment(): void { wp_set_current_user( self::$global_moderator_id ); $response = $this->dispatch_update( $this->make_comment( 'pingback' ) ); @@ -256,7 +259,7 @@ public function test_global_moderator_can_update_builtin_ping_comment() { * * @ticket 35214 */ - public function test_global_moderator_retains_control_over_unregistered_type() { + public function test_global_moderator_retains_control_over_unregistered_type(): void { wp_set_current_user( self::$global_moderator_id ); $webmention_id = $this->make_comment( 'webmention' ); @@ -273,7 +276,7 @@ public function test_global_moderator_retains_control_over_unregistered_type() { * * @ticket 35214 */ - public function test_global_moderator_can_update_legacy_empty_type_comment() { + public function test_global_moderator_can_update_legacy_empty_type_comment(): void { wp_set_current_user( self::$global_moderator_id ); $response = $this->dispatch_update( $this->make_comment( '' ) ); @@ -289,7 +292,7 @@ public function test_global_moderator_can_update_legacy_empty_type_comment() { /** * @ticket 35214 */ - public function test_review_moderator_can_read_review_comment_in_edit_context() { + public function test_review_moderator_can_read_review_comment_in_edit_context(): void { wp_set_current_user( self::$review_moderator_id ); $response = $this->dispatch_get( $this->make_comment( 'review' ), 'edit' ); @@ -300,7 +303,7 @@ public function test_review_moderator_can_read_review_comment_in_edit_context() /** * @ticket 35214 */ - public function test_global_moderator_cannot_read_review_comment_in_edit_context() { + public function test_global_moderator_cannot_read_review_comment_in_edit_context(): void { wp_set_current_user( self::$global_moderator_id ); $response = $this->dispatch_get( $this->make_comment( 'review' ), 'edit' ); @@ -311,7 +314,7 @@ public function test_global_moderator_cannot_read_review_comment_in_edit_context /** * @ticket 35214 */ - public function test_global_moderator_can_read_default_comment_in_edit_context() { + public function test_global_moderator_can_read_default_comment_in_edit_context(): void { wp_set_current_user( self::$global_moderator_id ); $response = $this->dispatch_get( $this->make_comment( 'comment' ), 'edit' ); @@ -331,7 +334,7 @@ public function test_global_moderator_can_read_default_comment_in_edit_context() * * @ticket 35214 */ - public function test_admin_can_read_orphaned_default_comment() { + public function test_admin_can_read_orphaned_default_comment(): void { wp_set_current_user( self::$admin_id ); $response = $this->dispatch_get( $this->make_comment( 'comment', 0 ) ); @@ -342,7 +345,7 @@ public function test_admin_can_read_orphaned_default_comment() { /** * @ticket 35214 */ - public function test_subscriber_cannot_read_orphaned_default_comment() { + public function test_subscriber_cannot_read_orphaned_default_comment(): void { wp_set_current_user( self::factory()->user->create( array( 'role' => 'subscriber' ) ) ); $response = $this->dispatch_get( $this->make_comment( 'comment', 0 ) ); @@ -357,7 +360,7 @@ public function test_subscriber_cannot_read_orphaned_default_comment() { * * @ticket 35214 */ - public function test_review_moderator_with_edit_cap_can_read_orphaned_review_comment() { + public function test_review_moderator_with_edit_cap_can_read_orphaned_review_comment(): void { $full_review_moderator = self::factory()->user->create_and_get( array( 'role' => 'subscriber' ) ); $full_review_moderator->add_cap( 'moderate_reviews' ); $full_review_moderator->add_cap( 'edit_others_reviews' ); @@ -375,7 +378,7 @@ public function test_review_moderator_with_edit_cap_can_read_orphaned_review_com * * @ticket 35214 */ - public function test_admin_cannot_read_orphaned_review_comment() { + public function test_admin_cannot_read_orphaned_review_comment(): void { wp_set_current_user( self::$admin_id ); $response = $this->dispatch_get( $this->make_comment( 'review', 0 ) ); @@ -391,7 +394,7 @@ public function test_admin_cannot_read_orphaned_review_comment() { * * @ticket 35214 */ - public function test_orphan_collection_gate_uses_global_primitive() { + public function test_orphan_collection_gate_uses_global_primitive(): void { $default_orphan = $this->make_comment( 'comment', 0 ); $review_orphan = $this->make_comment( 'review', 0 ); @@ -402,7 +405,9 @@ public function test_orphan_collection_gate_uses_global_primitive() { $response = rest_get_server()->dispatch( $request ); $this->assertSame( 200, $response->get_status() ); - $ids = wp_list_pluck( $response->get_data(), 'id' ); + $data = $response->get_data(); + $this->assertIsArray( $data ); + $ids = wp_list_pluck( $data, 'id' ); $this->assertContains( $default_orphan, $ids, 'The default-type orphan should be listed for an administrator.' ); $this->assertNotContains( $review_orphan, $ids, 'The independent-type orphan should be filtered out for an administrator.' ); @@ -414,4 +419,108 @@ public function test_orphan_collection_gate_uses_global_primitive() { $this->assertErrorResponse( 'rest_cannot_read', $response, 403 ); } + + /* + * Notes and reactions keep trunk behavior: `edit_comment`, not moderation, gates them. + */ + + /** + * Creates a note on the shared post, or a reaction to one, authored by the given user. + * + * @param string $comment_type Either 'note' or 'reaction'. + * @param int $user_id Comment author. + * @return int The new comment ID. + */ + private function make_editorial_comment( string $comment_type, int $user_id ): int { + $args = array( + 'comment_post_ID' => self::$post_id, + 'comment_type' => $comment_type, + 'comment_approved' => '1', + 'user_id' => $user_id, + ); + + if ( 'reaction' === $comment_type ) { + // An approved note is a resolved thread, whose reactions are frozen. + $args['comment_parent'] = self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'comment_type' => 'note', + 'comment_approved' => '0', + 'user_id' => self::$admin_id, + ) + ); + } + + return self::factory()->comment->create( $args ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_editorial_comment_types(): array { + return array( + 'note' => array( 'note' ), + 'reaction' => array( 'reaction' ), + ); + } + + /** + * A global moderator who cannot edit the post cannot read their own note or + * reaction in the edit context. + * + * @ticket 35214 + * + * @dataProvider data_editorial_comment_types + */ + public function test_global_moderator_cannot_read_editorial_comment_in_edit_context( string $comment_type ): void { + wp_set_current_user( self::$global_moderator_id ); + + $response = $this->dispatch_get( $this->make_editorial_comment( $comment_type, self::$global_moderator_id ), 'edit' ); + + $this->assertErrorResponse( 'rest_forbidden_context', $response, 403 ); + } + + /** + * @ticket 35214 + * + * @dataProvider data_editorial_comment_types + */ + public function test_post_editor_can_read_editorial_comment_in_edit_context( string $comment_type ): void { + wp_set_current_user( self::$admin_id ); + + $response = $this->dispatch_get( $this->make_editorial_comment( $comment_type, self::$admin_id ), 'edit' ); + + $this->assertSame( 200, $response->get_status() ); + } + + /** + * A global moderator who cannot edit the post cannot delete their own note or + * reaction. + * + * @ticket 35214 + * + * @dataProvider data_editorial_comment_types + */ + public function test_global_moderator_cannot_delete_editorial_comment( string $comment_type ): void { + wp_set_current_user( self::$global_moderator_id ); + + $response = $this->dispatch_delete( $this->make_editorial_comment( $comment_type, self::$global_moderator_id ) ); + + $this->assertErrorResponse( 'rest_cannot_delete', $response, 403 ); + } + + /** + * @ticket 35214 + * + * @dataProvider data_editorial_comment_types + */ + public function test_post_editor_can_delete_own_editorial_comment( string $comment_type ): void { + wp_set_current_user( self::$admin_id ); + + $response = $this->dispatch_delete( $this->make_editorial_comment( $comment_type, self::$admin_id ) ); + + $this->assertSame( 200, $response->get_status() ); + } } From a69dc759b4ddf9f8af04b8ebc79ab5b90ec87576 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Sun, 8 Nov 2026 09:31:20 -0500 Subject: [PATCH 6/7] Tests: Pin the destination check when type moderators move comments. check_edit_permission() now admits a type's own moderators, but moving a comment to another post still runs check_target_post_permission(), where only the site-wide `moderate_comments` primitive skips the `edit_post` check on the destination. That keeps a type-scoped moderator from placing comments on posts they cannot edit, such as another user's private post. Pins both sides of that rule plus the unchanged global moderator behavior. See #35214. --- .../rest-api/rest-comment-type-moderation.php | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/tests/phpunit/tests/rest-api/rest-comment-type-moderation.php b/tests/phpunit/tests/rest-api/rest-comment-type-moderation.php index 431c240661ea1..b45b5ad17026d 100644 --- a/tests/phpunit/tests/rest-api/rest-comment-type-moderation.php +++ b/tests/phpunit/tests/rest-api/rest-comment-type-moderation.php @@ -210,6 +210,65 @@ public function test_review_moderator_cannot_update_default_comment(): void { $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); } + /** + * Dispatches a REST request to move a comment to another post. + * + * @param int $comment_id Comment to move. + * @param int $post_id Destination post. + * @return WP_REST_Response + */ + private function dispatch_move( int $comment_id, int $post_id ): WP_REST_Response { + $request = new WP_REST_Request( 'POST', '/wp/v2/comments/' . $comment_id ); + $request->set_param( 'post', $post_id ); + + return rest_get_server()->dispatch( $request ); + } + + /** + * Only the site-wide `moderate_comments` primitive skips the destination + * check, so a type moderator cannot move a comment onto a post they cannot + * edit, such as another user's private post. + * + * @ticket 35214 + */ + public function test_review_moderator_cannot_move_review_comment_to_uneditable_post(): void { + $comment_id = $this->make_comment( 'review' ); + $target_id = self::factory()->post->create( + array( + 'post_status' => 'private', + 'post_author' => self::$admin_id, + ) + ); + + wp_set_current_user( self::$review_moderator_id ); + + $response = $this->dispatch_move( $comment_id, $target_id ); + + $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); + $comment = get_comment( $comment_id ); + $this->assertInstanceOf( WP_Comment::class, $comment ); + $this->assertSame( (string) self::$post_id, $comment->comment_post_ID, 'The comment should stay on its original post.' ); + } + + /** + * @ticket 35214 + */ + public function test_review_moderator_can_move_review_comment_to_editable_post(): void { + $moderator = self::factory()->user->create_and_get( array( 'role' => 'author' ) ); + $moderator->add_cap( 'moderate_reviews' ); + $comment_id = $this->make_comment( 'review' ); + $target_id = self::factory()->post->create( array( 'post_author' => $moderator->ID ) ); + + wp_set_current_user( $moderator->ID ); + + $response = $this->dispatch_move( $comment_id, $target_id ); + + $this->assertSame( 200, $response->get_status() ); + $comment = get_comment( $comment_id ); + $this->assertInstanceOf( WP_Comment::class, $comment ); + $this->assertSame( (string) $target_id, $comment->comment_post_ID ); + } + /* * Default capability model: unchanged from historical behavior. */ @@ -236,6 +295,22 @@ public function test_global_moderator_can_delete_default_comment(): void { $this->assertSame( 200, $response->get_status() ); } + /** + * A site-wide moderator may still move a default comment to any post. + * + * @ticket 35214 + */ + public function test_global_moderator_can_move_default_comment_to_another_post(): void { + $comment_id = $this->make_comment( 'comment' ); + $target_id = self::factory()->post->create( array( 'post_status' => 'private' ) ); + + wp_set_current_user( self::$global_moderator_id ); + + $response = $this->dispatch_move( $comment_id, $target_id ); + + $this->assertSame( 200, $response->get_status() ); + } + /** * The built-in pingback type uses the default model and is unaffected. * From b4f9139a71da7a5307c7dad79d7039273e5d9ba2 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Sun, 8 Nov 2026 09:36:20 -0500 Subject: [PATCH 7/7] Docs: Mark the new comment type APIs as @since 7.3.0. Trunk is in the 7.2 beta, so these additions can no longer ship in 7.2.0. --- .../endpoints/class-wp-rest-comments-controller.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php index b289cfd0701ea..1be401de3b975 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php @@ -479,7 +479,7 @@ protected function get_comment( $id ) { * Checks if a given request has access to read the comment. * * @since 4.7.0 - * @since 7.2.0 The edit context is gated by the `moderate_comment` meta capability. + * @since 7.3.0 The edit context is gated by the `moderate_comment` meta capability. * * @param WP_REST_Request $request Full details about the request. * @return true|WP_Error True if the request has read access for the item, error object otherwise. @@ -2408,7 +2408,7 @@ protected function prefetch_reaction_summaries( $note_ids ) { * Checks if the comment can be read. * * @since 4.7.0 - * @since 7.2.0 Orphaned comments are gated by the `moderate_comment` meta capability. + * @since 7.3.0 Orphaned comments are gated by the `moderate_comment` meta capability. * * @param WP_Comment $comment Comment object. * @param WP_REST_Request $request Request data to check. @@ -2443,7 +2443,7 @@ protected function check_read_permission( $comment, $request ) { * Checks if a comment can be edited or deleted. * * @since 4.7.0 - * @since 7.2.0 Moderators are checked with the `moderate_comment` meta capability. + * @since 7.3.0 Moderators are checked with the `moderate_comment` meta capability. * * @param WP_Comment $comment Comment object. * @return bool Whether the comment can be edited or deleted.