angeo / module-ucp
Spec-compliant Universal Commerce Protocol (UCP) profile generator for Magento 2. Serves /.well-known/ucp at protocol version 2026-08-25 with the canonical keys[] JWK Set (ES256/ES384/Ed25519), authority-bound capability schemas, and correct hosting/CORS headers.
Requires
- php: >=8.2
- ext-curl: *
- ext-json: *
- ext-openssl: *
- magento/framework: >=103.0.0
- magento/module-backend: >=102.0.0
- magento/module-config: >=101.0.0
- magento/module-store: >=101.0.0
Requires (Dev)
- magento/magento-coding-standard: *
- phpstan/phpstan: ^1.10
- phpunit/phpunit: ^10.0
Suggests
- ext-sodium: Required only for Ed25519 (EdDSA) signing keys, which the UCP spec recommends for Web Bot Auth interop. ES256 works without it.
- angeo/module-ucp-catalog: Serves the catalog.search / catalog.lookup / get_product endpoints this profile advertises
Provides
None
Conflicts
None
Replaces
None
This package is not auto-updated.
Last update: 2026-09-05 18:27:50 UTC
README
Publishes a Universal Commerce Protocol business profile at
/.well-known/ucp so AI shopping agents (Google/Gemini, ChatGPT, etc.) can
discover your store's commerce capabilities.
- Profile generated per UCP spec 2026-08-25 (current release) and
validated in CI against the official JSON Schemas from the spec
repository (
schemas/profile.json#business_schema) - Authority binding enforced locally. 2026-08-25 makes it a MUST that a
platform reject any entity whose
schemahost does not match its namespace — and it does so silently.angeo:ucp:validateruns the spec's own derivation algorithm so you find out before a platform does - Signing keys published in the canonical top-level
keys[]JWK Set (RFC 7517), public keys only. ES256, ES384 and Ed25519 supported, with RFC 7638 thumbprint key ids for Web Bot Auth interop, and zero-downtime rotation via--add - Multi-transport service bindings: REST out of the box, optional MCP
binding — extensible to further transports and services via
Api\ServiceBindingProviderInterfaceand the di.xml provider pool - Served by a PHP controller — correct
Content-Type: application/json, CORS, and cache headers, with no nginx/Apache changes
How the endpoint is served
/.well-known/ucp is delivered by a controller, not a static file, because the
UCP spec requires Content-Type: application/json and CORS headers
(Access-Control-Allow-Origin: *) — neither of which a static file without an
extension can provide without editing the web server.
| Component | Role |
|---|---|
Controller\Router |
Custom router matching the exact path /.well-known/ucp and dispatching the action. Registered in etc/di.xml via RouterList (sortOrder 22, before the CMS router). Returns null for any other path. |
Controller\WellKnown\Ucp |
Builds and returns the profile as JSON with Content-Type: application/json, CORS, Cache-Control: public, max-age=300, and hardening headers. Answers OPTIONS preflight with 204 + CORS headers. Returns 404 when the module is disabled (the site simply does not advertise UCP). Public, no auth — as the spec requires. |
Model\ProfileGenerator |
Builds the spec-2026-08-25 profile (services, capabilities, extensions, payment handlers, supported versions, public keys). |
Model\Spec |
Single source of truth for every spec/schema URL the profile publishes. |
Model\AuthorityBinding |
The spec's schema-URL authority-binding derivation algorithm. |
Model\Keys\KeyGenerator / JwkFormatter |
Generates EC and Ed25519 keys, formats the public half as a JWK, and computes RFC 7638 thumbprints. |
Why this reaches PHP without web-server changes
The stock Magento nginx config ends its main location with
try_files $uri $uri/ /index.php$is_args$args. A request for /.well-known/ucp
with no matching static file falls through to index.php, where the custom
router dispatches it. The official Magento nginx sample has no
location ~ /\. deny rule, so the dot-segment is not blocked.
If your host added a custom
location ~ /\. { deny all; }rule it blocks all dot-paths, and the profile then needs a one-line nginx allow for^~ /.well-known/. The stock config does not have this problem.
Do not leave a static file at
pub/.well-known/ucp: nginx would serve it first (asapplication/octet-stream, no CORS) and the controller would never run.
Inbound signature verification (2.1.0)
The profile publishes keys. 2.1.0 adds the half that uses them: verification of RFC 9421 signatures on requests arriving at your UCP endpoints.
This direction is the one the spec puts a MUST on — a business rejects a request whose counterparty profile cannot be fetched or fails validation. Before 2.1.0, endpoints advertised by a signed profile answered anyone who sent a POST.
Stores → Configuration → Angeo UCP → Request Security:
| Mode | Signed & valid | Signed & invalid | Unsigned |
|---|---|---|---|
disabled (default) |
served | served | served |
optional |
served | 401 | served |
required |
served | 401 | 401 |
Start in optional. An invalid signature is refused there too — only a
missing one is tolerated — so you get real enforcement while you find out,
from the log, which agents actually sign. There is no way to know that in
advance, which is why required is not the default.
Endpoint modules consume this through Api\SignatureVerifierInterface;
angeo/module-ucp-catalog 2.1.0 already does.
Not yet implemented: signing our own responses. Tracked for a later release.
Upgrading from 1.x
1.4.0 is not broken — it publishes a valid 2026-04-08 profile, and the spec
allows a business to keep serving an older version. This release adopts
2026-08-25, which is a package deal: the protocol version, the key field
and the capability documentation URLs all move together.
1.x (2026-04-08) |
2.0 (2026-08-25) |
|
|---|---|---|
| Signing keys | signing_keys[] |
keys[] (RFC 7517 JWK Set) |
Capability schema |
optional | required |
schema origin check |
string prefix on ucp.dev |
authority-binding algorithm |
spec origin check |
must be ucp.dev |
https only, any host |
| Catalog docs URL | /specification/catalog/… |
/specification/shopping/catalog/… |
| Key types | P-256 only | P-256, P-384, Ed25519 |
| Key id | random | RFC 7638 thumbprint |
composer require angeo/module-ucp:^2.0
bin/magento setup:upgrade && bin/magento cache:flush
bin/magento angeo:ucp:validate
Existing keys keep working and are simply republished under keys[]; no
regeneration is needed unless you want Web Bot Auth interop. If a platform
already integrates against your current profile, freeze a copy of it and
declare it under Advanced → Supported Versions — this module serves only
the version it advertises.
Install
composer require angeo/module-ucp bin/magento module:enable Angeo_Ucp bin/magento setup:upgrade bin/magento cache:flush
Generate signing keys, then verify:
bin/magento angeo:ucp:keys:generate # ES256, the universal baseline curl -sI https://yourstore.com/.well-known/ucp # HTTP/2 200 # content-type: application/json # access-control-allow-origin: * # cache-control: public, max-age=300
CLI
| Command | Purpose |
|---|---|
angeo:ucp:keys:generate |
Generate a signing keypair. --type=es256|es384|ed25519, --add to publish alongside existing keys, --kid to override the thumbprint. |
angeo:ucp:validate |
Validate the generated profile against the UCP spec, including authority binding. |
Which key type?
- ES256 (default) — the universal baseline. Every counterparty accepts it, and AP2 mandate signing requires it. Start here.
- Ed25519 — RECOMMENDED by the spec for Web Bot Auth interop on HTTP
transport. Needs
ext-sodium. It does not cover AP2, so if you sign mandates, publish both. - ES384 — for deployments with a stricter curve policy.
Rotating without downtime
A key is not effectively revoked until it is absent from keys[] — which
also means it is not published until it is present. So rotate in that order:
bin/magento angeo:ucp:keys:generate --add # publish the new key # move signing over to the new kid, let caches expire bin/magento angeo:ucp:keys:generate --force # drop everything but the newest
Configuration
Stores → Configuration → Angeo UCP — enable the module and declare which capabilities your store supports: catalog search/lookup, cart, checkout, order, identity linking, permalink, plus the fulfillment, discount and buyer consent extensions, payment handlers, and supported protocol versions.
Declaring a capability advertises it; it does not implement it. Enable a toggle only when the matching endpoint is live, or agents will send requests that fail. Permalink is the exception — it is satisfied by a stock Magento cart URL, so it is safe to turn on anywhere.
Security
- The profile is public and unauthenticated by design (per the UCP spec). Never put secrets, internal URLs, or admin contacts in it.
- Only public keys are published. The private key never leaves the server;
ProfileGeneratorreads public keys only (Config::getPublicKeys()), and any private JWK member (d,p,q,dp,dq,qi,oth,k) found in stored config is stripped before serving, with a warning telling you to rotate. SeeSECURITY.md. - The endpoint sends
X-Content-Type-Options: nosniff,X-Frame-Options: DENY, andReferrer-Policy: no-referrer. - Rate-limiting is the operator's responsibility (reverse proxy / WAF).
- If a CDN/Varnish fronts the site, the
Cache-Controlheader lets it cache the profile; purge/.well-known/ucpafter rotating keys or changing config.