Skip to content

Multisite: an OpenStation network of separate installs, with one switcher on every one - #756

Open
epeicher wants to merge 2 commits into
trunkfrom
multisite-network-registry
Open

Multisite: an OpenStation network of separate installs, with one switcher on every one#756
epeicher wants to merge 2 commits into
trunkfrom
multisite-network-registry

Conversation

@epeicher

@epeicher epeicher commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

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/identity publishes 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. add fetches the identity and pins the key; check re-fetches and flags unreachable or key-changed without ever replacing the pinned key.

  • The list (hub.php): GET /desktop-mode/v1/network answers a signed request from a pinned key (three headers, an Ed25519 signature over METHOD\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, admin on openstation_register_window() and App::admin(): site (the default, unchanged for every existing window), network, or any; openstation_native_window_offered_here() filters instead of emptying. The Network app declares network on a multisite.

    ->admin( is_multisite() ? 'network' : 'site' )
    ->can( fn( Os $os ) => $os->env->is_network() ? $os->auth->can( 'manage_network' ) : $os->auth->can( 'manage_options' ) )
  • Wire address vs. public URL: openstation_network_request_url filters 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) and bin/wp-env-network-dev.sh, which routes two local instances through Docker's host gateway with the public Host header (a multisite keyed on localhost:8890 sends any other Host to signup).

Testing instructions

npm run test:php && npm run test:php:multisite   # Tests_OpenStation_Network
  1. npm run env:start and npm run env:start:member.
  2. In the hub's network shell (localhost:8890/wp-admin/network/admin.php?page=openstation) open Network from the dock and add http://localhost:8892: it appears as Paired.
  3. In the member's shell (localhost:8892, admin / password) open Network and join http://localhost:8890: "This site belongs to …", list synced.
  4. Reload either shell and open Overview: the same row on both, Network Admin first, the hub's sites, then Member site.
  5. Stop the member and click Check sites on the hub: Unreachable. Start it again, Check sites: Paired.

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.

Open WordPress Playground Preview

@epeicher epeicher changed the title Multisite: every site is its own OpenStation, with a site switcher in overview Multisite: an OpenStation network of separate installs, with one switcher on every one Sep 3, 2026
@epeicher
epeicher changed the base branch from trunk to multisite-site-instances September 3, 2026 21:15
@epeicher
epeicher force-pushed the multisite-network-registry branch from 8968726 to 9cd811a Compare September 4, 2026 07:42
Base automatically changed from multisite-site-instances to trunk September 4, 2026 15:11
…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
epeicher force-pushed the multisite-network-registry branch from cf7978e to 412686e Compare September 4, 2026 15:11
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.

1 participant