Skip to content

REST API endpoints for sites - #13055

Open
lloc wants to merge 67 commits into
WordPress:trunkfrom
lloc:feature/40365-rest-sites-endpoint
Open

lloc wants to merge 67 commits into
WordPress:trunkfrom
lloc:feature/40365-rest-sites-endpoint

Conversation

@lloc

@lloc lloc commented Aug 14, 2026

Copy link
Copy Markdown

Trac ticket: https://core.trac.wordpress.org/ticket/40365

There are some open questions that we should discuss in the trac ticket.

@github-actions

github-actions Bot commented Aug 14, 2026 •

Copy link
Copy Markdown

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs.

Unlinked Accounts

The following contributors have not linked their GitHub and WordPress.org accounts: @jonnydmg.

Contributors, please read how to link your accounts to ensure your work is properly credited in WordPress releases.

Core Committers: Use this line as a base for the props when committing in SVN:

Props realloc, spacedmonkey, peterwilsoncc, biont, johnjamesjacoby.

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.


Props Bot commands

You can trigger Props Bot actions by commenting on this PR:

  • @propsbot add <username> to give props to a GitHub user. Requires write access.
  • @propsbot remove <username> to leave a user out of the props. Requires write access.
  • @propsbot refresh to refresh this list. Adding the props-bot label works too.

@github-actions

Copy link
Copy Markdown

Test using WordPress Playground

The changes in this pull request can previewed and tested using a WordPress Playground instance.

WordPress Playground is an experimental project that creates a full WordPress instance entirely within the browser.

Some things to be aware of

  • All changes will be lost when closing a tab with a Playground instance.
  • All changes will be lost when refreshing the page.
  • A fresh instance is created each time the link below is clicked.
  • Every time this pull request is updated, a new ZIP file containing all changes is created. If changes are not reflected in the Playground instance,
    it's possible that the most recent build failed, or has not completed. Check the list of workflow runs to be sure.

For more details about these limitations and more, check out the Limitations page in the WordPress Playground documentation.

Test this pull request with WordPress Playground.

@jonnydmg jonnydmg left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a big PR to review. Good work getting this ready for core. This code a little old and some things have changed in core, so this PR needs to update accordingly.

Biggest things.

  • We need to support sites with is_site_meta_supported false. There was some work done there to support it but not complete.
  • Site endpoint needs to be registered on single site. So it always exists then return an error on single site.
  • WP_REST_Site_Meta_Fields has no test coverage at all.
  • Single site needs tests.
  • Super admin should be allowed to do everything on the endpoint.
  • Code coverage and ticket need needs to be added to every test.

This feel like we are on the right track, if I am nitpicing here, becuase I think this good to go into core and just want to get the final bits to push forward into core.

I will wait until feedback is complete to do some real testing for this PR.

Comment thread src/wp-includes/rest-api.php Resolved

@jonnydmg jonnydmg left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a big PR to review. Good work getting this ready for core. This code a little old and some things have changed in core, so this PR needs to update accordingly.

Biggest things.

  • We need to support sites with is_site_meta_supported false. There was some work done there to support it but not complete.
  • Site endpoint needs to be registered on single site. So it always exists then return an error on single site.
  • WP_REST_Site_Meta_Fields has no test coverage at all.
  • Single site needs tests.
  • Super admin should be allowed to do everything on the endpoint.
  • Code coverage and ticket need needs to be added to every test.

This feel like we are on the right track, if I am nitpicing here, becuase I think this good to go into core and just want to get the final bits to push forward into core.

I will wait until feedback is complete to do some real testing for this PR.

Introduces WP_REST_Sites_Controller plus tests.
See #40365.
@lloc
lloc force-pushed the feature/40365-rest-sites-endpoint branch from aa03545 to cc81250 Compare August 22, 2026 07:51
@spacedmonkey

Copy link
Copy Markdown
Member

@lloc I have put together a little PR for tests for all the meta changes -lloc#1

@spacedmonkey spacedmonkey left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here is a PR with some unit test improvements.

lloc#2

This is has unit tests that cover single site as well.

lloc and others added 7 commits September 2, 2026 15:48
…oint-tests

Tests: Add unit tests for WP_REST_Site_Meta_Fields functionality
…oint-more-tests

Tests: Rename rest-sites-controller.php to wpRestSitesController.php …
Add format validation for the `domain` and `path` parameters in
WP_REST_Sites_Controller so malformed values are rejected with a clean
400 rest_invalid_param instead of failing later inside wp_insert_site()/
wp_update_site() (which surfaces as a 500).

domain must be a valid hostname or bare IPv4/IPv6 address, optionally
followed by a port (bracketed IPv6 is intentionally not supported, so
IPv6 addresses can't carry a port). path must start and end with a
forward slash and be made up of characters valid in a URL path segment.

Ticket #40365.
…t-check-exists' into feature/40365-rest-sites-endpoint-check-exists

# Conflicts:
#	src/wp-includes/rest-api/endpoints/class-wp-rest-sites-controller.php
@spacedmonkey

Copy link
Copy Markdown
Member

I have more feedback here.

lloc#4
lloc#3

This fixes some issues that were flagged by ai.

…oint-force-delete

Enhance site deletion process to support force deletion and user removal
…oint-check-exists

Check if domain exists before creating / updating.
@spacedmonkey

spacedmonkey commented Oct 5, 2026 •

Copy link
Copy Markdown
Member

@spacedmonkey

  1. No cross-network queries, the network_id parameter is hard coded to the current network in WP_Site_Query()
  2. The endpoint is only available on the main site, ie is_main_site() === true -- this matches the network admin behaviour in which /sub-site/wp-admin/network redirects to /wp-admin/network on the main site.

This will ensure that plugins and mu-plugins for the endpoint match what is loaded in the network admin currently.

Just a heads up, jonnydmg and spacedmonkey are both me!

  1. I agree, I have removed this functionality, but added a single filter, so plugins could add could add this functionality in if they want to. Using filters, rest_site_in_network ( new filter ), rest_site_collection_params ( to add network parameter ) and rest_site_query ( to update query ) and rest_pre_insert_site ( to update crud functions ). Between these filters that could be enough to restore this functionality. See REST API: Limit the sites endpoint to the current network lloc/wordpress-develop#11
  2. Limiting to main site is not really an option. One of the other planned use for this endpoint in core is "My sites" section in the admin toolbar. This is why is there is a user_id of "me" to get my sites. If we limit this endpoint to just the main site, then we will have corrs errors.

I know there could be something on the subsite that could tweak the permsissoins. But if you have a plugin doing that, you could do a lot, like regsitering your own sites endpoint or otherwise expose site data. I am not sure this is something we can lock down. You would hope the network maintainer would lock this down.

Thoughts @peterwilsoncc @lloc ?

…point-no-network

REST API: Limit the sites endpoint to the current network
@lloc

lloc commented Oct 6, 2026 •

Copy link
Copy Markdown
Author

I checked this against WP Multi Network. Peter's cross-network leak is already fixed: get_items is pinned to the current network, and site_in_network() blocks single sites on other networks.

Proposal:

  • Listing, edit context, create/update/delete: main site only (is_main_site(), which is per network) and current network only. These then run with the same plugins as /wp-admin/network. One caveat: is_network_admin() is false in REST, so mu-plugins that check it still won't load.
  • user=me in view context: allowed on every site and across networks, which avoids the CORS issue. The toolbar already lists these sites via get_blogs_of_user(), which ignores networks. The endpoint returns a few more fields (dates, post_count, lang_id), all harmless. GET /sites/{id} for member sites would need the same exemption for consistency.

Anyone who wants cross-network management can restore it through the rest_site_query and rest_site_in_network filters. WP Multi Network's own controller shows the pattern: switch_to_network(), then current_user_can( 'manage_network' ).

@peterwilsoncc @spacedmonkey Would that work for both of you?

@spacedmonkey

Copy link
Copy Markdown
Member

@lloc @peterwilsoncc

If we expose data to members of the site, should be consider making register meta only show if context=edit. Site meta would might have senative data in it.

@JJJ

JJJ commented Oct 6, 2026

Copy link
Copy Markdown
Contributor

Thanks everyone for continuing to work through this. @lloc, you’ve done a great job moving a large and complicated change forward, testing it with WP Multi Network, and narrowing the remaining disagreement.

I think @peterwilsoncc is likely right that the safest boundary for this controller is:

  1. Current-network access only.
  2. Main-site access only, so requests run with the same plugin environment as /wp-admin/network/.

I am hesitant to add a user=me cross-network exception. The core fields appear harmless but the response is extensible through registered site-meta, additional REST fields, and filters such as rest_prepare_site. The site-meta question raised above is one example of how the effective response can differ from the fixed fields in the controller.

I also don’t think the toolbar use-case needs to determine this controller’s security model, or necessarily needs to be implemented in core as part of this change. The toolbar already has a long history of accumulating responsibilities and blog/network queries, and my guess is we'd all prefer not to repeat some of those old design mistakes. 😆

If a client-side, cross-network "My Sites" API is needed later, that would be easier to review as a separate, read-only endpoint with a deliberately small and fixed response schema. (That keeps this controller aligned with network administration while giving the toolbar use case its own security and API-design discussion.)

My personal preference is to keep this PR focused on current-network management from the network's main site, without the cross-network member exception.

Thank you again everyone for all the work getting it this far. 💗

lloc and others added 4 commits October 7, 2026 12:45
…esponse.

Following the review on PR 13055, the sites endpoint now behaves like the
network admin and is only usable from the network's main site:

- Create, update, delete and single-site reads return a 403
  `rest_cannot_{create,edit,delete,view}_not_on_main_site` error from any
  other site.
- Listing your own sites (`user=me` or your own ID) and a member reading
  their site stay available from any site, but only in the view context.
- Users listing their own sites cannot filter or order by fields outside
  the view context (`rest_forbidden_param`), so hidden values cannot be
  inferred.
- The status flags, dates, `lang_id`, `post_count` and the site meta are
  now edit-context only.
- Add a read-only `admin_url` field and build `home` and `siteurl` with
  get_home_url() and get_site_url(), so their filters apply.
- Replace the write-only `title` property with a create-only, writable
  `blogname`, and move `user_id` out of the schema into the create route
  arguments. A POST to an existing site no longer runs the create path.
- Skip the site queries when the user filter matches no sites, while
  still firing `rest_site_query`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The collection filter `user` and the create-only `user_id` argument
described the same relationship under two names. Both are now `user`,
sharing one schema that accepts a positive user ID or "me", and
get_user_id_from_param() resolves "me" to the current user.

- Invalid values such as `xyz`, `0`, `-1` or an empty string are now
  rejected with `rest_invalid_param` before the permission check.
- Numeric strings are sanitized to integers.
- An unknown administrator on creation returns `rest_user_invalid_id`.
- Listing the sites of another user requires the capability to manage
  sites (`rest_forbidden_user`).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Both read permission checks reject `context=edit` up front with
  `rest_forbidden_context` unless the user can manage sites, before the
  member, main-site and site lookup checks, so an edit-context request
  does not reveal whether a site exists.
- get_items_permissions_check() resolves the own-user filter once and
  reuses it for the other-user check, so a logged-out `user=me` now
  returns `rest_forbidden_user`.
- The check against filtering or ordering by fields outside the view
  context now applies to every user without the capability, and still
  runs before the own-sites exemption.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@spacedmonkey

Copy link
Copy Markdown
Member

@lloc @peterwilsoncc @JJJ I've opened lloc#12 against feature/40365-rest-sites-endpoint with the changes from the discussion above. In summary:

Main site only

  • Create, update, delete and single-site reads only work from the network's main site. From any other site they return a 403: rest_cannot_{create,edit,delete,view}_not_on_main_site.
  • The "my sites" exceptions are kept narrow and only work in the view context: listing your own sites (user=me or your own ID), and a member reading their own site. embed is no longer exempt.

Permissions

  • context=edit requires manage_sites on both routes. It is checked before the site is looked up, so it doesn't reveal whether a site exists.
  • Listing another user's sites requires manage_sites (rest_forbidden_user).
  • Without manage_sites, you can't filter or order by fields you can't see (rest_forbidden_param). That covers archived, mature, spam, deleted, lang_id, lang_id_exclude, before, after and orderby=registered|last_updated. Otherwise the results would reveal those values.

Response and schema

  • The status flags, the dates, lang_id, post_count and the registered site meta are now edit context only. This addresses the concern @JJJ and I raised that extensible fields like meta could leak to members.
  • New read-only admin_url. home and siteurl now come from get_home_url() and get_site_url(), so their filters apply.
  • title duplicated blogname, so it's removed. blogname is now writable when a site is created.

user parameter

  • The user filter and the create-only user_id are now a single user parameter. It is validated as a positive user ID or "me", and invalid values return rest_invalid_param.

Other

  • If the user filter matches no sites, the site queries are skipped, but rest_site_query still fires.

All sites and site meta tests pass on multisite (254) and single site, and lint and PHPStan are clean.

Feedback welcome, particularly on:

  1. Whether keeping the view-context "my sites" exceptions is acceptable, given the main-site-only boundary.
  2. The set of edit-only fields.
  3. Merging user and user_id into one parameter.

@lloc

lloc commented Oct 10, 2026

Copy link
Copy Markdown
Author

Pushed a fix for the domain length @spacedmonkey flagged on 09-28.

domain now has maxLength: 200 and path has maxLength: 100, matching the varchar(200) and varchar(100) columns on wp_blogs in schema.php. Until now a longer value passed validation, failed in wpdb on insert, and came back as a 500 db_insert_error. It's now a 400 rest_invalid_param. I removed the 253 check in is_valid_domain() because the new limit is stricter. Both validation data providers have boundary cases, which run on create and update, and the QUnit fixture is regenerated.

It's all in one separate commit, so it's easy to revert if you'd rather handle this differently.

@lloc

lloc commented Oct 10, 2026

Copy link
Copy Markdown
Author

@peterwilsoncc @JJJ, could you look at @spacedmonkey's questions in lloc#12? The first one decides whether we keep the "my sites" exception. It's limited to the view context and the current network, but it still works from subsites. If you'd rather require the main site for everything, I'm happy to remove it.

@lloc
lloc requested a review from peterwilsoncc October 10, 2026 09:43
@lloc

lloc commented Oct 24, 2026 •

Copy link
Copy Markdown
Author

validate_path() now rejects paths that would take over network-wide routes. A site at /wp-json/ would capture the network's REST API, and a site at /wp-login.php/ would capture its login page. The first segment can't be a name from get_subdirectory_reserved_names(), and no segment can name a PHP file. Both checks ignore case, because sites are looked up case-insensitively. Core's signup and Add Site screens only accept a single [a-z0-9-] slug, but this endpoint accepts a full path, so the check has to live in the controller.

@peterwilsoncc

Copy link
Copy Markdown
Contributor

The first one decides whether we keep the "my sites" exception. It's limited to the view context and the current network, but it still works from subsites. If you'd rather require the main site for everything, I'm happy to remove it.

For the initial commit I'd like to remove the My Sites allowance and only keep it for the main network (this is where dataviews will be used).

The existing My Sites implementation is somewhat flawed in that it allows sub sites to modify it via plugins. It's something that can be solved with the switch to an API endpoint so that should be a goal of the new endpoint.

@lloc

lloc commented Nov 2, 2026

Copy link
Copy Markdown
Author

@peterwilsoncc I've removed the "My Sites" exception as suggested.

Every route now needs the main site and manage_sites. That includes listing (user=me too) and reading a single site in any context. Members get no special access from any site.

Removed:

  • is_own_user_filter() and has_edit_only_filter()
  • the rest_forbidden_user and rest_forbidden_param errors

Only network admins get past the permission check now, so none of these could trigger.

Kept:

  • the user filter, so super admins and DataViews can still list a user's sites
  • the view/edit split for fields and meta

One behaviour change: a logged-out request to a subsite now gets a 403 ("only from the main site") instead of a 401. The main-site check runs first and doesn't reveal anything about any site.

A cross-network "My Sites" API can come later as its own small, read-only endpoint, as @JJJ suggested.

@lloc

lloc commented Nov 2, 2026

Copy link
Copy Markdown
Author

I committed some improvements:

  • Block archiving, spamming, deleting, or moving the main site, which took the whole network down.
  • Validate site meta before writing, so an invalid value no longer leaves a half-created or half-updated site behind.
  • Move home and siteurl along with a new domain or path, unless they point elsewhere on purpose.
  • Restrict new paths to one site name of letters, numbers, and hyphens below the network path, like Add New Site. This rules out encoded characters, dot segments, and file names such as wp-login.php or robots.txt. Paths are lowercased.
  • Require the path when creating a site in a subdirectory install.
  • Keep the users on a soft delete, like flagging a site for deletion in Network Admin, so it can be undone.
  • Reject a body or query ID that does not match the ID in the URL.

@lloc

lloc commented Nov 6, 2026

Copy link
Copy Markdown
Author

@peterwilsoncc @spacedmonkey I integrated the REST API endpoints in the MultiSyde plugin and got Claude to test them: inpsyde/multisyde#73 (comment)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants