WP Registry server
WP Registry is an independent PHP/MySQL catalogue and package publisher for WordPress installations. The public portal is https://wpregistry.studiochampgauche.com; the registry is https://area.wpregistry.studiochampgauche.com. The portal presents the live catalogue and configured WordPress client download. The registry origin serves the signed API, immutable ZIP downloads and authenticated publication interface. Both use the same private configuration and database. No domain, DNS record, account or deployment is created by this source checkout.
The server imports the entire currently public plugin and theme inventory, page by page, then downloads and inspects the latest archive of every discovered project. Native catalogue/install responses contain only locally available, verified archives. An incomplete import never becomes a request-time proxy to WordPress.org. status distinguishes a complete metadata enumeration from a complete verified mirror. Historical versions, closed/private projects, ratings/reviews, screenshots, upstream search ranking and the upstream tag/feature taxonomy are not replicated by this initial implementation. Plugin/theme tag and feature endpoints return empty collections. The public directory is live rather than a transactional snapshot: rerun enumeration when distinct observed items do not reach the maximum reported upstream count. Do not announce a complete mirror until both catalogue mirror_complete values are true and failed queues have been investigated.
cPanel layout and requirements
The portal, internal publishing, publisher accounts and moderation share a monochrome interface. /manage/ is Publications internes, with an explicit project picker and grouped release fields; /publish/ is Espace éditeur; /moderate/ is Modération. Their authentication, capabilities and publication behavior are unchanged. Existing installations can apply wp-registry-visual-update.zip in the account home using its included French guide. The update contains only view templates and presentation assets; it excludes .htaccess, configuration, keys and data. Build it from configs/ with node ./scripts/run-registry-php.mjs ./scripts/package-wp-registry-visual-update.php. No database initialization or WordPress client rebuild is required for this visual update.
Use dist/releases/wp-registry-cpanel.zip for the complete installation. Extract it in the account home, alongside public_html. It provides registry-private/config.example.php, wp-registry-server/ and both public domain roots. The portal serves /home/ACCOUNT/public_html/wpregistry.studiochampgauche.com; the registry serves /home/ACCOUNT/public_html/area.wpregistry.studiochampgauche.com. Follow the included French INSTALLATION.fr.md, whose source is the cPanel installation guide. Populated configuration and generated keys are never included. The separate wp-registry-server.zip is a source archive for development, not the complete cPanel delivery.
Use the account's supported PHP CLI and PHP-FPM build, PHP 7.4 or newer, with curl, pdo_mysql, sodium, zip, mbstring, and json. Public publisher accounts additionally require Argon2id password hashing. PHP 7.4 is the source compatibility floor; deploy a maintained PHP release. Use MySQL 5.7+/MariaDB 10.2+ with InnoDB and FULLTEXT support. Choose a current maintained database release. No Node, Redis, WordPress bootstrap, Composer packages or always-running application daemon is required on the account.
The complete cPanel kit installs this account-relative tree:
/home/ACCOUNT/wp-registry-server/
cli.php
schema.sql
lib/
src/ <-- private administration and portal templates
client-template/
public/ <-- implementations called by fixed public wrappers
index.php
.htaccess
manage/
/home/ACCOUNT/registry-private/
config.php <-- account-only permissions 0600
signing.key <-- generated by init, 0600
packages/ <-- immutable ZIPs, not PHP-executable web paths
tmp/ <-- bounded transfer/normalization staging
/home/ACCOUNT/public_html/wpregistry.studiochampgauche.com/
index.php <-- public portal entry
.htaccess
portal.css
favicon.svg
/home/ACCOUNT/public_html/area.wpregistry.studiochampgauche.com/
index.php <-- fixed API/download wrapper
.htaccess
manage/
publish/
moderate/
Keep the private directory in mode 0700. Copy its packaged config.example.php to config.php and fill database credentials privately. The example derives private_dir and public_dir from the account location. Never upload populated configuration, signing keys, backups or logs into either domain root. Public and private directories must be separate canonical directories; links and nested/overlapping roots are rejected.
Set the two domain document roots to the sibling paths shown above. Install TLS certificates covering both exact names and enforce HTTPS at Apache. The fixed sibling registry-private/config.php is loaded relative to the application directory. UR_CONFIG=/home/ACCOUNT/registry-private/config.php is an optional explicit PHP-FPM/Apache and cron/CLI override. An invalid explicit override fails closed; request headers, query parameters and the working directory cannot select configuration. Leave ModSecurity enabled. The checked-in rewrite files require Apache 2.4 with rewrite enabled. Apache/cPanel Directory Privacy is required separately for public_html/area.wpregistry.studiochampgauche.com/manage and public_html/area.wpregistry.studiochampgauche.com/moderate.
Create a dedicated database and two MySQL users through cPanel:
- CLI/admin writer: schema creation during
init/publishers-init, then SELECT/INSERT/UPDATE/DELETE on this dedicated database. No other application's database grants. - Public API user: SELECT on
ur_state,ur_projects,ur_packages,ur_releases,ur_translations,ur_imports, andur_publisher_project_state; SELECT/INSERT/UPDATE onur_rateonly. Do not grant access to publisher accounts, sessions, codes, ownership, audits or review tables. It cannot publish, replace or withdraw packages. Populatedb_user/db_passwordwith this user anddb_writer/db_writer_passwordwith the writer.
The account's PHP process needs read access to the signing key to sign API responses and write access to package/tmp storage for authenticated publication. Use a separate cPanel account from customer sites; a compromised site sharing the Unix identity could access this publisher's key. Make application source read-only to normal application writes where the hosting configuration supports it. Back up the database, immutable packages and signing key together, encrypted outside the document root. Preserve the monotonic ur_state.revision across restores; a rollback below revisions already seen by clients is refused by those clients. Key changes require an explicit trusted client reconfiguration or new provider build.
No automatically generated private material belongs in Git or the downloadable client ZIP. Only the provider origin and Ed25519 public key are embedded in the client.
Initialization and client distribution
All examples below run in the account's shell with its actual PHP CLI executable. The environment is private operator configuration, not web input.
export UR_CONFIG=/home/ACCOUNT/registry-private/config.php
php /home/ACCOUNT/wp-registry-server/cli.php init
php /home/ACCOUNT/wp-registry-server/cli.php publishers-init
php /home/ACCOUNT/wp-registry-server/cli.php status
php /home/ACCOUNT/wp-registry-server/cli.php build-client --source=/home/ACCOUNT/wp-registry-server/client-template --output=/home/ACCOUNT/registry-private/wp-registry-0.3.0.zip
php /home/ACCOUNT/wp-registry-server/cli.php publish --metadata=/home/ACCOUNT/wp-registry-server/client-release.json --archive=/home/ACCOUNT/registry-private/wp-registry-0.3.0.zip
init is repeatable and uses CREATE TABLE IF NOT EXISTS/INSERT IGNORE; it does not reset the revision or replace an existing signing key. publishers-init adds publisher-account tables and private authentication state to new or existing registries. Neither command is a general future migration framework. build-client rejects an existing output filename and builds an ordinary installable wp-registry/ plugin directory. It replaces only provider.php with the configured public provider identity. WP Registry self-updates must contain this exact provider configuration; publishing an inert or foreign provider.php is rejected. Rebuild the provider client for each new client release, and publish that ZIP with a new immutable version.
An arbitrary theme or plugin is packaged as a ZIP with one root directory equal to its slug. Use the built plugin/theme directory when distributing ReactWP artifacts. The main plugin file's basename and its Version, Author URI, and Update URI headers must match publication metadata. A theme must have its matching version in the root style.css. File names and ZIP roots are validated before publication.
The home page at https://wpregistry.studiochampgauche.com/ links directly to the configured plugin download. A published available client takes precedence over registry-private/wp-registry-0.3.0.zip. Both require the exact provider identity and an actual plugin version of at least 0.3.0. A suspended, withdrawn or obsolete published client cannot fall back to the bootstrap. No public request generates an archive or imports content. The portal is read-only apart from bounded rate counters; private publisher data is not exposed.
Complete catalogue import and recurring sync
php /home/ACCOUNT/wp-registry-server/cli.php import-catalog --type=plugin --pages=10
php /home/ACCOUNT/wp-registry-server/cli.php import-catalog --type=theme --pages=10
php /home/ACCOUNT/wp-registry-server/cli.php sync-packages --limit=10
php /home/ACCOUNT/wp-registry-server/cli.php status
Run the catalogue commands repeatedly until metadata enumeration finishes, and the archive command repeatedly until ready counts reach imported metadata counts. Each page contains up to 100 projects; --pages is limited to 100. sync-packages --limit is limited to 100 archives. Completed pages, ready archives and failed attempts persist in MySQL, so interrupted cron runs resume. One database advisory writer lock serializes imports and publication. A competing run fails promptly rather than overlap. Failed archives back off from 5 minutes to 24 hours; per-run output reports safe project slug/error codes and status reports failed counts. Metadata remains visible in operator status while unavailable archives remain absent from installable catalogue results.
To refresh the inventory after a completed scan, start a new generation once with --restart=1, then resume without that flag:
php /home/ACCOUNT/wp-registry-server/cli.php import-catalog --type=plugin --pages=10 --restart=1
Do not put --restart=1 in every frequent cron invocation: that would restart from page one. A daily starter and frequent resumptions can be separate cPanel cron jobs. On a growing catalogue choose a window long enough to finish one generation before restarting. Archive imports retain already published immutable versions. Upstream removals do not silently delete previously mirrored packages; the operator must review directory changes and publication policy. The first mirror may consume substantial disk space, network traffic and time; measure a bounded batch on the real account before setting cron frequency or buying storage. No full catalogue download has been executed during source development.
The remote allowlist is exact HTTPS hosts: metadata/translations use api.wordpress.org, plugin/theme/translation ZIPs use downloads.wordpress.org, with no GitHub Core import. Transfers pin a validated public IPv4 DNS result, disallow proxies, verify TLS, allow at most three validated redirects, connect within 10 seconds and finish within 120 seconds. ZIPs are limited to 128 MiB, 512 MiB expanded, 30,000 entries and 64 MiB per entry; extreme compression, traversal, case aliases, path collisions, encryption, unsupported compression and special/symlink entries are rejected. An upstream package that violates these limits remains a reported failure; it is never silently counted as a complete mirror.
WordPress Core
WP Registry handles plugins and themes only. WordPress keeps its native Core updates, checksums and Core translations. There is no Core import command or update API. Existing stored data is retained. Version 0.3.0 changes the product name, plugin slug and provider origins; see the migration guide before replacing an earlier installation.
Translations
php /home/ACCOUNT/wp-registry-server/cli.php import-translations --type=plugin --slug=sample --version=1.0.0 --limit=10
Run repeatedly for each published project/version until the returned complete value is true. The initial translation importer keeps the first successfully published archive for a version/locale immutable; later upstream translation revisions need a future explicit revision workflow. Public update checks accept at most 20 requested locales and include already imported language packs in one bounded batch query for canonical owned installed projects. Missing language archives are not proxied upstream.
Translation ZIPs contain only the exact package-owned MO, PO and hashed JSON filenames. Executable .l10n.php entries are removed during normalization; WordPress uses the MO fallback. Foreign plugin/theme/locale namespaces and unexpected files reject publication. The descriptor binds translation_type, slug, version and language.
Publishing and release management
Verified public publisher accounts use /publish/ to create their own projects and publish releases autonomously. /moderate/ gives configured operators ownership review, release history and moderation controls. The detailed account, ownership and moderation workflow is documented in PUBLISHERS.fr.md.
The account operator can also publish through CLI. The legacy /manage/ workflow remains under cPanel Directory Privacy and configured exact publisher grants. See src/Admin.php. Every legacy authenticated principal must own a distinct exact plugin:slug or theme:slug entry in config.php's publishers map; wildcards are rejected. This interface requires direct HTTPS, a server-established allowlisted REMOTE_USER, secure session cookies and CSRF checks. It never accepts an identity from PHP_AUTH_USER, proxy/request headers or publication form values. Use a supported cPanel configuration that supplies REMOTE_USER to PHP-FPM and verify a real denied anonymous HTTP request before use.
Example non-secret local metadata file:
{
"type": "plugin",
"slug": "sample",
"name": "Sample",
"version": "1.0.0",
"plugin": "sample/sample.php",
"author": "Studio Champ Gauche",
"author_uri": "https://champgauche.studio",
"update_uri": "false",
"homepage": "https://wpregistry.studiochampgauche.com",
"requires": "6.0",
"requires_php": "7.4",
"tested": "6.8",
"description": "Plain text description.",
"adoption_identities": []
}
php /home/ACCOUNT/wp-registry-server/cli.php publish --metadata=/home/ACCOUNT/registry-private/sample-1.0.0.json --archive=/home/ACCOUNT/registry-private/sample-1.0.0.zip
php /home/ACCOUNT/wp-registry-server/cli.php promote --type=plugin --slug=sample --version=1.0.0
php /home/ACCOUNT/wp-registry-server/cli.php withdraw --type=plugin --slug=sample --version=1.0.0
Metadata fields are an explicit allowlist; unknown keys fail. Name is 200 UTF-8 bytes, author 200, description 24,000, URLs 2,048 and version 64. Slugs are at most 191 lowercase characters, with bounded ./_/- segments; plugin basenames are at most 255 bytes and must be within the slug root. Description/name/author are canonical plain text, escaped by the UI/client at their rendering sink. adoption_identities is CLI-only, at most five explicit basename/update URI/author URI tuples. The web interface preserves it and cannot edit it. Local publications cannot replace an existing WordPress.org-owned namespace. Missing both author identity and a meaningful update URI cannot authorize automatic updates, even if a basename matches. Literal Update URI: false alone is not proof of ownership; ReactWP plugins use the exact author URI alongside their basename and can be explicitly adopted.
Published (type, slug, version) is immutable. Changing its bytes requires a new version. Promoting an existing nonwithdrawn release changes the offered current version; WordPress's ordinary update check still offers only newer versions, so promotion is not an automatic client downgrade. Withdrawal removes its active offer and makes fresh signed package lookup unavailable; WP Registry clients perform this lookup before installation. Installed sites are not remotely changed. Previously downloaded or CDN-cached immutable ZIP bytes remain public; withdrawal is not a confidentiality or remote-uninstall mechanism.
The private account operator can also remove a harmful or otherwise disallowed imported plugin/theme release without transferring its namespace to a local publisher:
php /home/ACCOUNT/wp-registry-server/cli.php withdraw-imported --type=plugin --slug=upstream-slug --version=1.0.0
withdraw-imported requires an exact wordpress.org source and is deliberately absent from the browser administration routes. Ordinary withdraw and web publishers retain their local-only source checks. It marks that immutable release withdrawn, removes the matching current offer, refreshes mirror readiness and advances the signed revision. A later sync of the same upstream version cannot restore it; its failed retry uses the normal import backoff. Withdrawal is version-scoped, so a distinct newer upstream version can be imported and reviewed separately. Previously distributed immutable ZIP bytes remain public under the same retention policy as local releases.
Public v1 protocol and bounds
All API routes are JSON POST requests, without a trailing slash or query string. Maximum request body is 1 MiB; maximum signed response is 4 MiB. Catalogue pages contain at most 100 projects and page numbers are bounded to 10,000. Update inventories contain at most 500 plugin/theme entries. Public endpoints perform no upstream HTTP work and mutate only fixed-size rate-limit buckets. Each bucket allows 120 API requests per minute and there are at most 4,096 buckets. Direct client address is REMOTE_ADDR; any trusted reverse proxy must restore it server-side. Configure bandwidth/connection limits at the host/CDN for immutable ZIP serving.
Routes: /v1/plugins/query, /information, /tags; /v1/themes/query, /information, /features; /v1/updates/plugins, /themes, /translations; /v1/packages/information. Query/information data follows native WordPress shapes. Plugin/theme updates use plugins/themes and no_update keyed by exact basename/slug, plus translations. /v1/packages/information accepts sha256 and an optional 64-hex-character nonce. Requests and errors never contain credentials. Missing resources return HTTP 404, invalid requests 400, unavailable infrastructure/rate limits 503 with stable generic JSON. Errors are not signed success responses.
Catalogue author filtering matches an exact imported author username or a bounded display-name prefix; values are prepared SQL parameters. Unsupported nonempty tag filters fail explicitly. Newer plugin/theme releases retain their requires and requires_php constraints even when the requesting site is older: native WordPress displays those requirements and controls installation instead of being falsely told there is no update. Canonical plain descriptions are HTML-escaped at the native description/section response boundary so markup examples remain literal text.
The envelope is {payload: base64(rawJSON), signature: base64(Ed25519(rawJSON))}. Its exact decoded JSON contains schema:1, request:sha256("POST\n" + route + "\n" + exactBody), monotonic revision, Unix issued_at, expires_at 900 seconds later and data. The client verifies all fields, expiry, provider public key, descriptor identity, package SHA-256 and archive structure. Package URLs are only the configured origin plus /packages/<sha256>.zip. No cross-origin CDN redirect is advertised.
Verification and operational limits
npm run test:registry:portal checks public routing, bounded search, real catalogue data, suspension/withdrawal, escaped presentation, initial-client identity and HTTP downloads. Supply the local MySQL test environment below to exercise those integrations. With UR_TEST_PORTAL_WORDPRESS=1, it additionally downloads the actual client over HTTP, installs and activates that ZIP in an isolated native WordPress, and consumes the same running registry's signed plugin/theme catalogues and packages, with native WordPress Core behavior preserved. This uses temporary local databases and blocks external network and mail. Chrome presentation fixtures are covered separately by wp-registry-portal-view-browser.mjs.
From configs/, npm run test:registry:publishers exercises the account and moderation services, real HTTP registration/verification/login, multipart publication, cross-account refusals, operator correction and restoration, plus rendered forms. It needs the same private local MySQL test environment described below. The HTTP harness intercepts all mail in a temporary fixture router and never sends real messages. Its server-established HTTPS/Basic context is a test fixture, not proof of real cPanel TLS or authentication.
npm run package:registry:cpanel builds the complete cPanel ZIP with both sibling public domain roots. npm run test:registry:cpanel extracts that actual ZIP into a temporary account tree, checks hashes and private/public boundaries, verifies the unconfigured portal home, runs CLI initialization twice, creates the configured client, checks its portal download and verifies a signed API response. With the local database environment it also creates a disposable restricted API user and verifies that publisher credentials and write operations are denied. Both the test database and its temporary user are removed afterward; running this test requires local permissions to create these isolated fixtures.
The application and private data remain outside both web roots. Registry public PHP entries use fixed paths to the private implementation; the portal template is private too. Public Apache rules restrict serving to the intended entry points and assets, while Directory Privacy protects the registry's administrative child directories. With UR_TEST_APACHE_HOME set to an existing Apache installation, node ./scripts/run-registry-php.mjs ./tests/wp-registry-apache.test.php exercises the rules on a temporary loopback server, including public routing, anonymous/authenticated moderation and private-file denials. It does not modify the installed Apache configuration.
The account source field contract is maintained in PublisherFields::fieldSpecs()/validate(). Its test-side contract, rendered-view fixtures and HTTP harness live under configs/tests/wp-registry-publisher* and configs/tests/wp-registry-account-http.test.php. There is no request-controlled test or mail bypass in production entry points.
configs/tests/wp-registry-server.test.php covers signed exact-byte payloads/nonces, metadata mass assignment, exact publisher identities, malicious ZIP paths/case collisions/symlinks/compression, translation namespaces/PHP stripping, normalized pinned core fixtures, real-source provider ZIP creation and public/private confinement. With UR_TEST_MYSQL_DSN (server DSN without dbname), UR_TEST_MYSQL_USER, and UR_TEST_MYSQL_PASSWORD, it creates and drops only a unique ur_test_<random> database and also exercises schema, publication, immutable versions, withdrawal, SQL inputs, transaction rollback, rate buckets, resumable catalogue pages and archive-failure backoff. Test credentials are never committed. UR_TEST_EXPORT_PROTOCOL optionally exports ephemeral actual signed public responses for a separate client integration harness; no private key is exported.
Source tests do not prove actual CloudLinux PHP-FPM environment propagation, cPanel authentication, TLS/DNS/CDN behavior, production permissions, complete mirror size or fleet-scale performance. These require measurements and HTTP verification on the real account. Keep the initial domain private or in staging until those checks and complete import counters pass.
UR_TEST_SCALE=100000 optionally seeds that many synthetic catalogue rows in the disposable test database. Measured locally with PHP 8.3/MySQL, page one of 100 items took approximately 70 ms on the first request and 49 ms on repeat; page 1,000 took 219 ms, final signed responses were about 95 KiB and peak PHP memory about 14 MiB. The same fixture originally required roughly 1.1 seconds per first page before counting a covered project index and hydrating release metadata only after pagination. This is single-process local fixture evidence, not a production capacity claim.