angeo / module-ucp
angeo/module-ucp
Spec-compliant Universal Commerce Protocol (UCP) profile generator for Magento 2. Generates /.well-known/ucp at protocol version 2026-04-08 with ECDSA P-256 signing keys, declared capabilities, and proper cache headers. v0.1.x is profile-only — catalog, cart, checkout endpoints land in later releases.
Angeo UCP — /.well-known/ucp for Magento 2
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-04-08
- Served by a PHP controller — correct
Content-Type: application/json,
CORS, and cache headers, with no nginx/Apache changes - ECDSA P-256 signing keys, JWK-formatted, public keys only in the profile
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. 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-04-08 profile (services, capabilities, extensions, payment handlers, supported versions, public signing keys). |
Model\Keys\KeyGenerator / JwkFormatter |
Generates ECDSA P-256 keys and formats the public half as a JWK. |
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.
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
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 |
|---|---|
bin/magento angeo:ucp:keys:generate |
Generate / rotate the ECDSA P-256 signing key pair. |
bin/magento angeo:ucp:validate |
Validate the generated profile against the UCP spec. |
Configuration
Stores → Configuration → Angeo UCP — enable the module and declare which
capabilities your store supports (catalog search/lookup, cart, checkout, order,
identity linking, fulfillment, discount), payment handlers, and supported
protocol versions.
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 signing keys are published. The private key never leaves the
server;ProfileGeneratorreads public keys only (getPublicSigningKeys()).
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.
Changelog
All notable changes to angeo/module-ucp are documented here.
Format follows Keep a Changelog.
Versioning follows Semantic Versioning.
[1.2.0] - 2026-06-13
Fixes the endpoint wiring so /.well-known/ucp is actually served by the
controller, and removes a conflicting static-file code path.
Fixed
- Router registered in the wrong area — endpoint always 404'd. The
RouterListentry for the custom router was placed in the global
etc/di.xml. The frontend "standard"RouterListis frontend-scoped, so a
global registration is not reliably applied to it: the router'smatch()
never ran and every request fell through to the CMS router (HTTP 404,
text/html, with Magento page-cache tags). The registration is now in
etc/frontend/di.xml, which actually wires the router into frontend routing.
This was the underlying cause behind the earlier 404 reports. - 404 on LiteSpeed / Hostinger (and other non-nginx servers). The router
matched onlygetPathInfo()with a strict comparison. On LiteSpeed the
PATH_INFOfor a dot-segment path is frequently empty, so the comparison
failed and the request fell through to Magento's CMS router (HTTP 404,
text/html). The router now checksgetPathInfo(),getOriginalPathInfo()
andgetRequestUri(), strips the query string, and normalises slashes, so it
matches/.well-known/ucpregardless of how the web server populates the path. - Missing DI preference for
ProfileGeneratorInterface. The controller and
theangeo:ucp:validatecommand depend on the interface, but no
<preference>bound it toModel\ProfileGenerator, sosetup:upgradefailed
with "Cannot instantiate interface Angeo\Ucp\Api\ProfileGeneratorInterface".
This was latent until the router was wired up (nothing previously
instantiated the controller). The preference is now declared inetc/di.xml. - Custom router was never registered.
Controller\Routerexisted but no
RouterListentry referenced it (and there was noetc/frontend/routes.xml),
so the controller was dead code. As a result/.well-known/ucpeither
returned 404, or — when a static file was present — was served by the web
server asapplication/octet-streamwith no CORS headers, failing UCP
validation. The router is now registered inetc/di.xmlviaRouterList
(sortOrder 22) and aetc/frontend/routes.xmldeclares the route, so the
controller serves the endpoint with the correct headers. - Missing CORS headers. The controller now sends
Access-Control-Allow-Origin: *andAccess-Control-Allow-Methods: GET, OPTIONS
on every response, as required for cross-origin fetches by AI agents.
Removed
- Static-file approach deleted.
Service\WellKnownWriter,
Service\UcpProfileBuilder, and theangeo:ucp:generatecommand
(Console\Command\GenerateWellKnown) were removed. Writing a static
pub/.well-known/ucpconflicted with the controller — the web server served
the static file first, with the wrong Content-Type — and the builder was a
stub duplicate ofModel\ProfileGenerator. The controller is now the single
source of truth for the endpoint. - Removed a duplicate
di.xmlfrom the module root (Magento only reads
etc/di.xml).
Housekeeping
- Removed macOS
__MACOSXresource-fork artifacts from the package.
[1.1.0] - 2026-06-12
Full review against the live UCP 2026-04-08 specification at
ucp.dev. One
spec-compliance defect was found and fixed; the profile generator now covers
every business-profile feature defined by the spec.
Fixed — spec compliance
- [SPEC] Catalog capability naming corrected. The spec defines catalog as
TWO granular capabilities —dev.ucp.shopping.catalog.searchand
dev.ucp.shopping.catalog.lookup— with distinct spec pages
(/specification/catalog/search,/specification/catalog/lookup) and
schemas (catalog_search.json,catalog_lookup.json). The previous
monolithicdev.ucp.shopping.catalogcapability does not exist in the spec
and would not match any platform's declared capability during the
intersection algorithm, silently disabling catalog for all AI agents.
Backwards compatible: the legacycatalog_enabledconfig value is still
honoured and enables both granular capabilities.
Added — full business-profile coverage
- Extensions with
extendsdeclarations per spec:dev.ucp.shopping.fulfillment(extendsdev.ucp.shopping.checkout)dev.ucp.shopping.discount(extends checkout and/or cart; emits a string
for a single parent and an array for multiple parents, per spec)
- Orphaned-extension pruning. Extensions are never advertised when no
parent capability is enabled — the spec prunes orphans during negotiation,
so advertising one is always dead weight. The generator enforces this and
the validator reports it as an error. - Identity-linking OAuth scopes. New admin field publishes
config.scopes(e.g.dev.ucp.shopping.order:read) under
dev.ucp.common.identity_linking; scope values encode as empty JSON
objects{}exactly as the spec example shows. supported_versions(spec SHOULD for businesses supporting older
protocol versions): admin JSON field, validated — keys must be YYYY-MM-DD,
URIs must be HTTPS; invalid entries are skipped with a logged warning.payment_handlersdeclaration: admin JSON field, validated to be a
JSON object keyed by reverse-domain handler name; arrays and malformed
JSON are rejected with a logged warning. Documentation warns that profile
contents are public and must not contain secret keys.- Validator upgraded to a spec-conformance checker:
- capability required fields (
version,spec,schema— spec: REQUIRED) - service-binding required fields (
version,spec,transport) - Spec URL Binding:
dev.ucp.*capabilities must point at
https://ucp.dev/origins (spec: MUST; platforms reject mismatches) - orphaned-extension detection
- trailing-slash warning on endpoints (spec: SHOULD NOT)
supported_versionsformat checks- private-key-material detection in published
signing_keys
- capability required fields (
Changed
- README rewritten with an explicit protocol-coverage matrix documenting
which UCP layers this module implements (discovery) and which require
separate endpoint implementations (negotiation, transactional REST
endpoints, RFC 9421 response signatures, payment handler execution). - Admin capability section renamed to "Declared Root Capabilities" with a
new "Declared Extensions" section; comments clarify that enabling a
capability advertises it but does not implement the endpoint. module.xmlsetup_version and composer version bumped to 1.1.0.
Notes — protocol coverage
This module deliberately implements the discovery layer of UCP
(business profile + signing keys + hosting rules). The transactional flow —
capability negotiation, catalog/cart/checkout/order endpoints, RFC 9421
message signatures, payment execution — is the responsibility of endpoint
modules that this profile advertises. Enable capability toggles only when
the matching endpoint implementation is installed.
[1.0.0] - 2026-06-11
First production-ready release. All items below are security fixes or
reliability improvements over the 0.1.x beta line.
Security
-
[SECURITY] HTTP endpoint validation in
Config::getRestEndpoint().
Configured and auto-derived endpoint URLs are now checked for the
https://scheme. Awarningis emitted tosystem.logif a
non-HTTPS URL is detected, alerting the operator before AI agents
reject the profile. The UCP spec requires HTTPS for/.well-known/ucp. -
[SECURITY] Over-length coordinate rejection in
JwkFormatter.
Coordinates longer than 32 bytes (the P-256 fixed width) are now
rejected with aRuntimeExceptioninstead of being silently included.
Previously only short coordinates were left-padded; an over-long value
from a malformed key would have produced an invalid JWK without error. -
[SECURITY] Security response headers added to all controller responses.
X-Content-Type-Options: nosniff,X-Frame-Options: DENY, and
Referrer-Policy: no-referrerare now set on every response from the
UCP controller (200, 404, and 500). This aligns with standard Magento
hardening practices. -
[SECURITY] JWK entry validation before returning public keys.
Config::getPublicSigningKeys()now validates that each entry in the
stored JWK array contains the required fields (kid,kty,crv,
x,y) before including it in the profile. Malformed entries are
skipped and awarningis logged. -
[SECURITY] Empty
kidrejection inJwkFormatter.
publicKeyToJwk()now throws if$kidis an empty string, preventing
a JWK with a blank key identifier from being emitted.
Fixed
-
GenerateKeysCommandscope mismatch.
The existing-key check now reads atSCOPE_TYPE_DEFAULT(matching the
scope used byConfigWriter::saveConfig()). Previously reading at
store scope could return an empty string even when a key was already
stored at default scope, allowing the command to silently overwrite it. -
GenerateKeysCommandusesinvalidate()instead ofcleanType().
TypeListInterface::invalidate('config')marks the config cache as
dirty without flushing unrelated cache types.cleanType()is a
heavier operation that is unnecessary here. -
ValidateProfileCommandHTTPS check.
The validator now explicitly checks that every declared service-binding
endpoint URL useshttps://. Previously a profile with anhttp://
endpoint would pass validation even though the UCP spec forbids it. -
ValidateProfileCommandseverity adjustment.
The "no capabilities and no signing keys" condition is now awarning
rather than a hardFAILURE. A discovery-only deployment that has
no capabilities yet is structurally valid; operators are warned but
the command exits0so CI pipelines are not broken during initial
setup. -
KeyGeneratorusesextension_loaded()instead offunction_exists()
to check for OpenSSL.function_exists('openssl_pkey_new')is true
even when the extension is partially initialised on some platforms;
extension_loaded('openssl')is the canonical check. -
KeyGeneratorkid entropy increased from 2 to 4 bytes.
4 bytes of CSPRNG output (8 hex characters, ~4 billion distinct values)
makes accidental kid collisions negligible even in automated
high-rotation environments. -
module.xmlnow declaressetup_version="1.0.0".
Removed
extra.branch-aliasremoved fromcomposer.json; no longer needed
for a stable tagged release.
[0.1.1-beta] - 2026-05-23
Fixed
- Removed the static
"version"field fromcomposer.json. Packagist
now reads the version from the git tag, which is the canonical
Composer practice and eliminates the risk of the manifest version
drifting from the released tag.
Changed
- Installation now requires the explicit
@betastability qualifier:
composer require angeo/module-ucp:^0.1@beta. - README updated: roadmap row, install instructions, and a note that
0.1.0is yanked. extra.branch-aliasmappingdev-mainto0.1.x-devadded.
[0.1.0-beta] - 2026-05-23
Fixed
- Critical:
composer.jsonwas invalid JSON (trailing comma after
support.source), preventingcomposer requirefrom succeeding. magento/module-storeis now explicitly required incomposer.json
and listed inmodule.xml<sequence>.- Removed dead
etc/frontend/routes.xml. - Removed unused imports in
Controller/WellKnown/Ucp.php.
Changed
Angeo\Ucp\Model\Confignow takes aPsr\Log\LoggerInterfaceconstructor argument.Controller\WellKnown\Ucpnow catches non-JsonExceptionthrowables.
Added
SECURITY.md,ext-opensslandext-jsoncomposer requirements.- Two new
ConfigTestcases covering logging behaviour.
[0.1.0] - 2026-05-21 (yanked — composer.json was invalid JSON)
Added
- Initial public release.
| Version | Stability | QA Status | Compatibility | Released |
|---|---|---|---|---|
| 1.2.0 | stable | Fail | Magento 2.4.7-2.4.9 Details | 2026-06-14 18:53:11 |
| 0.1.1-beta | beta | Fail | Magento 2.4.7-2.4.9 Details | 2026-05-23 17:53:53 |
| 0.1.0-beta | beta | Not tested | Not yet tested Details | 2026-05-23 17:28:26 |
Requires 7
| Package | Constraint |
|---|---|
| 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 |
| php | >=8.2 |
Requires-dev 3
| Package | Constraint |
|---|---|
| magento/magento-coding-standard | * |
| phpstan/phpstan | ^1.10 |
| phpunit/phpunit | ^10.0 |
Compatibility
Each Magento release line is installed on its supported PHP versions, then the module is built (DI compilation + static-content deploy) and its unit and integration suites are run. The matrix shows the lines and PHP versions the module is confirmed to install and run on. Code-quality results further down (phpstan, phpcs, …) are reported separately and never affect compatibility.
Code Quality
Advisory checks against the module's source. Static analysis runs once across the whole module; PHPStan re-runs per Magento + PHP version because resolvable symbols differ between releases. These NEVER affect the Compatibility badge. A phpcs finding can't make a module incompatible.
Static analysis
Coding standards (phpcs), mess detection (phpmd), copy-pasted code (cpd), PHP cross-version compatibility, composer.json validity. Each runs once for the whole module.
| Tool | Status | Findings | Summary |
|---|---|---|---|
| PHPCS | Fail | 52 | 3 errors, 49 warnings (ruleset: Magento2) |
| PHPMD | Warning | 23 | 23 rule violations (MissingImport:13, NPathComplexity:3, CyclomaticComplexity:2, ExcessiveMethodLength:2, ExcessiveClassComplexity:1) |
| Cpd | Pass | 0 | |
| Composer validate | Info | 5 | valid; 5 advisory notes (composer validate --strict) |
Tests
Unit and integration suites, run for each applicable Magento and PHP version. A test failure speaks to the module's behaviour, not its compatibility with a Magento line, so it is reported here separately and never reddens the compatibility matrix.
Unit tests
Integration tests
| Magento | PHP 8.2 | PHP 8.3 | PHP 8.4 | PHP 8.5 |
|---|---|---|---|---|
| 2.4.7 | N/A | N/A | ||
| 2.4.8 | N/A | N/A | ||
| 2.4.9 | N/A | N/A |
Security
Security checks run directly against the module: an audit of its declared dependencies for known vulnerabilities (composer audit) and a scan of its source for malware and web-shell signatures. Each runs once. A malware detection fails the version outright.
More from angeo
View vendorMagento 2 module for AI Engine Optimization (AEO). Generates spec-compliant llms.txt and llms-full.txt per llmstxt.org standard, plus streaming JSONL for vector indexing. Multi-store, multi-website, CLI, cron, async admin UI, Page Builder-aware sanitization, customer-group pricing, atomic writes, ETag/Cache-Control, .md mirrors.
Magento 2 module for AI Engine Optimization (AEO). Injects AI crawler rules (OAI-SearchBot, GPTBot, ChatGPT-User, PerplexityBot, Perplexity-User, Google-Extended, ClaudeBot, anthropic-ai, Claude-User, Applebot, cohere-ai, Amazonbot, Meta-ExternalAgent) into robots.txt — without overwriting your existing configuration. Supports per-bot Allow/Disallow lists, Crawl-delay, Sitemap directives, multi-store, and a public Api\RobotsStatusInterface for cross-module integration with angeo/module-aeo-audit.
Live AI brand visibility audit for Magento 2. Queries ChatGPT, Claude, Perplexity, Gemini and Groq with brand-probing prompts and scores real-world AI recall, citation rate and recommendation presence. Extends angeo/module-aeo-audit v3 via CheckerInterface as the 16th signal, alongside the 15 built-in technical checks.
Magento 2 AEO (AI Engine Optimization) Audit. v3 covers 15 signals — robots.txt AI bots, llms.txt + llms.jsonl, Product / Organization / FAQ schema, merchant return + shipping policies, sitemap.xml, UCP profile, AI product feed, OG tags, canonical + hreflang, JSON-LD quality, well-known endpoint matrix, Core Web Vitals via CrUX. Score Trend dashboard, Admin UI, cron, dynamic fix commands, dependency-injected extension point for custom checkers.
Turn an existing module into recurring revenue.
If you already maintain a Magento 2 module on GitHub or GitLab, listing it on Packagento takes about five minutes. We mirror your tags, handle distribution signing, and route paid licenses through Stripe Connect, so you can keep shipping the way you already do.