Multisite: an OpenStation network of separate installs, with one switcher on every one - #756
Open
epeicher wants to merge 2 commits into
Open
Multisite: an OpenStation network of separate installs, with one switcher on every one#756epeicher wants to merge 2 commits into
epeicher wants to merge 2 commits into
Conversation
epeicher
force-pushed
the
multisite-network-registry
branch
from
September 4, 2026 07:42
8968726 to
9cd811a
Compare
…cher on every one Separate WordPress installs, each with OpenStation, now show the same site switcher and move between each other like sites of one network. A hub keeps the list: a multisite always is one, managed from its network admin's shell; a single site becomes one by admitting its first member. Every install owns an Ed25519 keypair, generated on first use, and publishes its public key on GET /desktop-mode/v1/network/identity. Pairing is one address on each side. The hub adds a member by URL, fetches its identity and pins its key; the member enters the hub's URL, pins the hub's key and asks GET /desktop-mode/v1/network for the list with a signed request (method, route, timestamp), which the hub answers for pinned keys only. A key that later differs is flagged and refused. The member caches the list and refreshes it in the background when stale, so the shell never waits on another install. On the hub the members are appended after the local sites; on a member the same multisite block is built from the cached list, with this site current. The Network app is the one-time admin task behind it: every site with its status, Add site, Check sites and Remove on the hub; the network it belongs to, Sync now and Leave on a member; both doors on a site in neither role. It lives in the network admin's shell on a multisite, which needed one opt-in in the App Framework: a window says which admin offers it (admin: site, network or any, on openstation_register_window() and App::admin()), and the native-window payload keeps the ones that belong instead of leaving the network admin empty. Identity requests never follow redirects, and plain HTTP is allowed only in local and development environments, which is what lets two wp-env instances pair: .wp-env.member.json adds a member instance on :8892, and bin/wp-env-network-dev.sh routes the two through Docker's host gateway with the public Host header.
Plugin Check runs PHPCS under its own ruleset and does not read phpcs.xml.dist, so the App Framework's esc() and tag() helpers count as unescaped output there and the plugin-check job failed on twenty lines of the Network window. Each output line now carries the same scoped phpcs:ignore the Station Home view uses, with the reason on the line.
epeicher
force-pushed
the
multisite-network-registry
branch
from
September 4, 2026 15:11
cf7978e to
412686e
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What it does
Separate WordPress installs, each with OpenStation, show one site switcher and move between each other as if they were sites of one network. A hub (a multisite's network admin, or a single site that admitted others) keeps the list; a member pairs with it once, with one address on each side, and shows the same row. The Network app is the one-time admin task behind it: every site with its status, Add site, Check sites and Remove on the hub; the network it belongs to, Sync now and Leave on a member; both doors on a site in neither role.
Stacked on #741. Login on arrival across installs, through a signed hop token, is the next PR.
Rationale
The switcher from #741 rests on a shared database:
get_sites(), per-admin sessions, one set of salts. An install elsewhere shares nothing, and registering it as a network site (how the two wpcomstaging sites got into the local network) builds a hollow clone: eighteen tables, default content claiming its address, and every network screen treating it as local. What an install can do on its own is sign and verify, so the network is made of two things: a key per install, pinned by its peers when pairing, and one place that keeps the list.Considered a pairing code (a shared secret per member) instead of keypairs. Simpler crypto, but only the hub could then sign anything, so every member-to-member switch would need a hub round-trip and the hub up.
Implementation
Identity (
includes/network/keys.php,identity.php): one Ed25519 keypair per install, generated on first use and kept as an install-wide option (a network option on a multisite).GET /desktop-mode/v1/network/identitypublishes name, URL, shell screen and public key. WordPress guarantees the crypto through its bundled sodium polyfill.Registry (
registry.php): members keyed by an id derived from their canonical URL.addfetches the identity and pins the key;checkre-fetches and flagsunreachableorkey-changedwithout ever replacing the pinned key.The list (
hub.php):GET /desktop-mode/v1/networkanswers a signed request from a pinned key (three headers, an Ed25519 signature overMETHOD\nROUTE\nTIMESTAMP, five minutes of skew) or an administrator. Members come after the local sites; the Network Admin's rows are all sent, since a member cannot know a user's network role.Member (
member.php): join pins the hub and asks for the list, in either order (the member shows it is waiting until the hub adds it). The list is cached on the hub entry and refreshed on cron when older than an hour, never on the request that paints the shell.openstation_network_member_payload()builds the same multisite block the shell always boots with, so the switcher needed no client change.App Framework opt-in: native windows were emptied in the network admin wholesale. A window now says which admin offers it,
adminonopenstation_register_window()andApp::admin():site(the default, unchanged for every existing window),network, orany;openstation_native_window_offered_here()filters instead of emptying. The Network app declaresnetworkon a multisite.Wire address vs. public URL:
openstation_network_request_urlfilters the URL one install reaches another by (a proxy, an internal hostname, a container), identity and keys stay keyed by the public URL, and identity requests never follow redirects.Local test bed:
.wp-env.member.json(npm run env:start:member, port 8892) andbin/wp-env-network-dev.sh, which routes two local instances through Docker's host gateway with the publicHostheader (a multisite keyed onlocalhost:8890sends any other Host to signup).Testing instructions
npm run env:startandnpm run env:start:member.localhost:8890/wp-admin/network/admin.php?page=openstation) open Network from the dock and addhttp://localhost:8892: it appears as Paired.localhost:8892, admin / password) open Network and joinhttp://localhost:8890: "This site belongs to …", list synced.The trust rules are pinned in PHPUnit: a key the hub never pinned, a stale signature, a signature over another route, a member whose key changed, and a member joining before the hub added it.