Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# next version

- Serve `robots.txt` again on Shopware `6.7.1.0` to `6.7.4.x` instead of answering `500`.
- Apply a sales channel's own UCP settings to requests under a domain with a path, such as `https://shop.example/de`. These requests used the UCP settings of the host's root domain. Under the `strict` signature policy, correctly signed requests to such a domain are no longer refused. Agents only discover a UCP profile at `/.well-known/ucp` on the root of a domain, so a sales channel that agents should find needs a domain without a path. A domain with a path no longer serves a UCP profile, and its storefront pages and discovery files no longer link one. Until now `/de/.well-known/ucp` served the profile of the host's root domain. `ucp:config:validate` now warns about a UCP sales channel whose domains all have a path.

# 1.4.0

Expand Down
1 change: 1 addition & 0 deletions CHANGELOG_de-DE.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# next version

- Unter Shopware `6.7.1.0` bis `6.7.4.x` liefert der Shop `robots.txt` wieder aus, statt mit `500` zu antworten.
- Für Requests unter einer Domain mit Pfad, etwa `https://shop.example/de`, gelten jetzt die UCP-Einstellungen des Verkaufskanals, dem diese Domain gehört. Bisher galten für sie die UCP-Einstellungen der Stammdomain des Hosts. Gilt die Signaturrichtlinie `strict`, werden korrekt signierte Requests an eine solche Domain nicht mehr abgelehnt. Agenten suchen ein UCP-Profil nur unter `/.well-known/ucp` im Wurzelverzeichnis einer Domain, deshalb braucht ein Verkaufskanal, den Agenten finden sollen, eine Domain ohne Pfad. Eine Domain mit Pfad liefert kein UCP-Profil mehr aus, und ihre Storefront-Seiten sowie ihre Discovery-Dateien verweisen auf keines mehr. Bisher lieferte `/de/.well-known/ucp` das Profil der Stammdomain des Hosts aus. `ucp:config:validate` warnt jetzt, wenn alle Domains eines für UCP freigegebenen Verkaufskanals einen Pfad enthalten.

# 1.4.0

Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,9 @@ the `ucp-php-sdk`.
### Set up UCP on a sales channel

The whole path from "plugin activated" to "first UCP request answered", for one sales channel. You
need a Storefront or Headless sales channel with at least one domain; every other channel type is
refused.
need a Storefront or Headless sales channel; every other channel type is refused. Agents find the
channel only through a domain without a path, such as `https://de.shop.example`, because a domain
like `https://shop.example/de` serves no UCP profile.

1. **Configure the channel in one step.**

Expand Down
45 changes: 25 additions & 20 deletions src/AgenticFiles/ApiCatalog/ApiCatalogLinksetBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use Shopware\Core\Framework\Api\ApiDefinition\DefinitionService;
use Shopware\Core\Framework\Log\Package;
use Swag\AgenticCommerce\Ucp\Profile\UcpProfileLocator;

/**
* Builds the RFC 9727 `/.well-known/api-catalog` document as an RFC 9264 linkset,
Expand All @@ -23,36 +24,40 @@ final class ApiCatalogLinksetBuilder

public const API_CATALOG_PATH = '/.well-known/api-catalog';

private const UCP_PROFILE_PATH = '/.well-known/ucp';
public function __construct(
private readonly UcpProfileLocator $profileLocator,
) {
}

/**
* @return array{linkset: list<array<string, mixed>>}
*/
public function build(string $baseUrl): array
{
// An empty base URL yields valid root-relative URI references in the linkset.
return [
'linkset' => [
$apiCatalogAnchorEntry = ['anchor' => $baseUrl.self::API_CATALOG_PATH];

$ucpProfileUrl = $this->profileLocator->profileUrl($baseUrl);
if (null !== $ucpProfileUrl) {
// The UCP profile is the shop's machine-readable API description; it is linked
// with the same `service-meta` relation the storefront already advertises via
// the UCP profile Link header.
$apiCatalogAnchorEntry['service-meta'] = [
[
'anchor' => $baseUrl.self::API_CATALOG_PATH,
// The UCP profile is the shop's machine-readable API description; it is linked
// with the same `service-meta` relation the storefront already advertises via
// the UCP profile Link header.
'service-meta' => [
[
'href' => $baseUrl.self::UCP_PROFILE_PATH,
'type' => 'application/json',
],
],
// The Store API entry point is a member API of the catalog (RFC 9727 `item`).
'item' => [
[
'href' => $baseUrl.'/'.DefinitionService::STORE_API,
'type' => 'application/json',
],
],
'href' => $ucpProfileUrl,
'type' => 'application/json',
],
];
}

// The Store API entry point is a member API of the catalog (RFC 9727 `item`).
$apiCatalogAnchorEntry['item'] = [
[
'href' => $baseUrl.'/'.DefinitionService::STORE_API,
'type' => 'application/json',
],
];

return ['linkset' => [$apiCatalogAnchorEntry]];
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@

{% block agentic_ai_catalog_entries %}
{# Keep this inline: Twig includes cannot mutate the caller's entries array, and this fallback owns JSON rendering. Keep in sync with the core override. #}
{% if swag_agentic_commerce_ucp_active(salesChannel) and salesChannelFileContext.publisher|default(null) and salesChannelFileContext.baseUrl|default(null) %}
{% if swag_agentic_commerce_ucp_active(salesChannel) and salesChannelFileContext.publisher|default(null) and salesChannelFileContext.baseUrl|default(null) and swag_agentic_commerce_ucp_profile_url(salesChannelFileContext.baseUrl) %}
{% set entries = entries|merge([{
identifier: 'urn:air:' ~ salesChannelFileContext.publisher ~ ':resource:ucp-profile',
displayName: 'Shopware UCP Profile',
type: 'application/json',
url: salesChannelFileContext.baseUrl ~ '/.well-known/ucp',
url: swag_agentic_commerce_ucp_profile_url(salesChannelFileContext.baseUrl),
description: 'Machine-readable Universal Commerce Protocol profile for enabled commerce capabilities on this sales channel.',
tags: ['shopware', 'ucp', 'commerce'],
representativeQueries: [
Expand Down
10 changes: 10 additions & 0 deletions src/Resources/config/services.php
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,8 @@
use Swag\AgenticCommerce\Ucp\Gateway\ShopwareDataMapperInterface;
use Swag\AgenticCommerce\Ucp\Gateway\ShopwareOrderGateway;
use Swag\AgenticCommerce\Ucp\Http\ConfiguredUrlSafetyValidatorFactory;
use Swag\AgenticCommerce\Ucp\Http\PathPrefixRestoringRequestContextFactory;
use Swag\AgenticCommerce\Ucp\Http\PathPrefixRestoringResponseSignatureService;
use Swag\AgenticCommerce\Ucp\Identity\CleanupExpiredOAuthTokensTask;
use Swag\AgenticCommerce\Ucp\Identity\CleanupExpiredOAuthTokensTaskHandler;
use Swag\AgenticCommerce\Ucp\Identity\ShopwareIdentityLinkingAdapter;
Expand Down Expand Up @@ -175,6 +177,8 @@
use Ucp\Sdk\Contract\IdentityLinkingCapabilityInterface;
use Ucp\Sdk\Contract\OrderCapabilityInterface;
use Ucp\Sdk\Contract\TokenizationCapabilityInterface;
use Ucp\Sdk\Service\HttpRequestContextFactoryInterface;
use Ucp\Sdk\Service\ResponseSignatureServiceInterface;
use Ucp\Sdk\Service\RuntimeConfigurationResolverInterface;
use Ucp\Sdk\Symfony\Bridge\EmbeddedPageRendererInterface;

Expand Down Expand Up @@ -537,6 +541,12 @@
$services->set(ShopwareRuntimeConfigurationResolver::class)
->arg('$profileFetchingDevelopmentMode', env('bool:default:defaults_bool_false:SWAG_AGENTIC_COMMERCE_UCP_PROFILE_FETCHING_DEVELOPMENT_MODE'));
$services->alias(RuntimeConfigurationResolverInterface::class, ShopwareRuntimeConfigurationResolver::class);
$services->set(PathPrefixRestoringRequestContextFactory::class)
->decorate(HttpRequestContextFactoryInterface::class)
->arg('$inner', service('.inner'));
$services->set(PathPrefixRestoringResponseSignatureService::class)
->decorate(ResponseSignatureServiceInterface::class)
->arg('$inner', service('.inner'));
// Fetched from the container by the plugin's activate()/update() hooks.
$services->alias(AgenticFilesCoreBridgeInterface::class, CoreSalesChannelFileBridge::class)->public();

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{% if swag_agentic_commerce_ucp_active(salesChannel) %}
{% if swag_agentic_commerce_ucp_active(salesChannel) and swag_agentic_commerce_ucp_profile_url(salesChannelFileContext.baseUrl|default(null)) %}
## Agentic commerce via UCP

- Check `/.well-known/ucp` before using UCP. Only use capabilities and transports advertised for this sales channel.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{% if swag_agentic_commerce_ucp_active(salesChannel) %}
{% if swag_agentic_commerce_ucp_active(salesChannel) and swag_agentic_commerce_ucp_profile_url(salesChannelFileContext.baseUrl|default(null)) %}
## Agentic commerce

- [UCP profile](/.well-known/ucp): Machine-readable Universal Commerce Protocol profile for this sales channel.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@

{% block agentic_ai_catalog_entries %}
{# Keep this inline: this override must merge before parent() so core entries stay intact. Keep in sync with the fallback template. #}
{% if swag_agentic_commerce_ucp_active(salesChannel) and salesChannelFileContext.publisher|default(null) and salesChannelFileContext.baseUrl|default(null) %}
{% if swag_agentic_commerce_ucp_active(salesChannel) and salesChannelFileContext.publisher|default(null) and salesChannelFileContext.baseUrl|default(null) and swag_agentic_commerce_ucp_profile_url(salesChannelFileContext.baseUrl) %}
{% set entries = entries|merge([{
identifier: 'urn:air:' ~ salesChannelFileContext.publisher ~ ':resource:ucp-profile',
displayName: 'Shopware UCP Profile',
type: 'application/json',
url: salesChannelFileContext.baseUrl ~ '/.well-known/ucp',
url: swag_agentic_commerce_ucp_profile_url(salesChannelFileContext.baseUrl),
description: 'Machine-readable Universal Commerce Protocol profile for enabled commerce capabilities on this sales channel.',
tags: ['shopware', 'ucp', 'commerce'],
representativeQueries: [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use Shopware\Core\Framework\Event\BeforeSendResponseEvent;
use Shopware\Core\Framework\Log\Package;
use Swag\AgenticCommerce\Ucp\Config\UcpConfigService;
use Swag\AgenticCommerce\Ucp\Profile\UcpProfileLocator;
use Swag\AgenticCommerce\Ucp\SalesChannel\SalesChannelDomainResolver;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;

Expand All @@ -19,6 +20,7 @@ final class UcpProfileLinkHeaderSubscriber implements EventSubscriberInterface
public function __construct(
private readonly UcpConfigService $configService,
private readonly SalesChannelDomainResolver $domainResolver,
private readonly UcpProfileLocator $profileLocator,
) {
}

Expand All @@ -36,10 +38,11 @@ public function onBeforeSendResponse(BeforeSendResponseEvent $event): void
return;
}

$salesChannelResolution = $this->domainResolver->resolveByAbsoluteUri($event->getRequest()->getUri());
$resolvedDomain = $this->domainResolver->resolveByRequest($event->getRequest());
if (
null === $salesChannelResolution
|| !$this->isUcpActiveForSalesChannel($salesChannelResolution->salesChannelId)
null === $resolvedDomain
|| null === $this->profileLocator->profileUrl($resolvedDomain->baseUrl)
|| !$this->isUcpActiveForSalesChannel($resolvedDomain->salesChannelId)
) {
return;
}
Expand Down
15 changes: 11 additions & 4 deletions src/Ucp/Command/UcpSetupCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
use Swag\AgenticCommerce\Ucp\Config\Validation\Finding;
use Swag\AgenticCommerce\Ucp\Config\Validation\Severity;
use Swag\AgenticCommerce\Ucp\Config\Validation\UcpConfigValidator;
use Swag\AgenticCommerce\Ucp\Profile\UcpProfileLocator;
use Swag\AgenticCommerce\Ucp\SalesChannel\SalesChannelDomainView;
use Swag\AgenticCommerce\Ucp\SalesChannel\SalesChannelView;
use Swag\AgenticCommerce\Ucp\SalesChannel\SalesChannelViewProvider;
Expand Down Expand Up @@ -61,6 +62,7 @@ public function __construct(
private readonly UcpConfigService $configService,
private readonly UcpSigningKeyService $signingKeyService,
private readonly UcpConfigValidator $validator,
private readonly UcpProfileLocator $profileLocator,
) {
parent::__construct();
}
Expand Down Expand Up @@ -274,12 +276,17 @@ private function renderFindings(SymfonyStyle $io, array $findings): void
private function renderSummary(SymfonyStyle $io, UcpConfig $config, SalesChannelView $channel): void
{
$io->section('Result');
$profileUrls = array_map(
static fn (SalesChannelDomainView $domain): string => rtrim($domain->url, '/').'/.well-known/ucp',
$profileUrls = array_values(array_filter(array_map(
fn (SalesChannelDomainView $domain): ?string => $this->profileLocator->profileUrl($domain->url),
$channel->domains,
);
)));
$profileUrlSummary = match (true) {
[] === $channel->domains => '(no domain on this sales channel yet)',
[] === $profileUrls => '(none: every domain of this sales channel has a path)',
default => implode("\n", $profileUrls),
};
$io->definitionList(
['Profile URL' => [] === $profileUrls ? '(no domain on this sales channel yet)' : implode("\n", $profileUrls)],
['Profile URL' => $profileUrlSummary],
['Signature policy' => $config->signaturePolicy],
['Idempotency required' => $config->idempotencyRequired ? 'yes' : 'no'],
['Platform allowlist' => [] === $config->platformAllowlist ? '(none: no platform can talk to this channel yet)' : implode(', ', $config->platformAllowlist)],
Expand Down
4 changes: 3 additions & 1 deletion src/Ucp/Config/ShopwareRuntimeConfigurationResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use Shopware\Core\Framework\Log\Package;
use Swag\AgenticCommerce\Compatibility\ShopwareVersionDetector;
use Swag\AgenticCommerce\Ucp\Http\PathPrefixRestorer;
use Swag\AgenticCommerce\Ucp\SalesChannel\SalesChannelDomainResolver;
use Ucp\Sdk\Model\Config\RuntimeConfiguration;
use Ucp\Sdk\Model\Http\HttpRequest;
Expand All @@ -19,13 +20,14 @@ public function __construct(
private readonly UcpConfigService $configService,
private readonly SalesChannelDomainResolver $domainResolver,
private readonly ShopwareVersionDetector $versionDetector,
private readonly PathPrefixRestorer $pathPrefixRestorer,
private readonly bool $profileFetchingDevelopmentMode = false,
) {
}

public function resolve(HttpRequest $request): RuntimeConfiguration
{
$resolution = $this->domainResolver->resolveByAbsoluteUri($request->absoluteUri);
$resolution = $this->domainResolver->resolveByAbsoluteUri($this->pathPrefixRestorer->restore($request->absoluteUri));
$config = $this->configService->getConfig($resolution?->salesChannelId);
$baseUri = $this->fallbackBaseUri($request->absoluteUri);
if (null !== $resolution) {
Expand Down
35 changes: 35 additions & 0 deletions src/Ucp/Config/Validation/UcpConfigValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ public function validate(
$this->checkSigningKeys($signingKeys, $salesChannelId, $now, $add);
$this->checkExposureReadiness($config, $add);
$this->checkProfileDomain($config, $domains, $add);
$this->checkPathPrefixedDomains($domains, $add);
$this->checkIdempotency($config, $salesChannelId, $add);
$this->checkEmbedded($config, $salesChannelId, $add);

Expand Down Expand Up @@ -199,6 +200,40 @@ private function checkProfileDomain(UcpConfig $config, array $domains, callable
);
}

/**
* @param array<mixed> $domains
*/
private function checkPathPrefixedDomains(array $domains, callable $add): void
{
$pathPrefixedUrls = [];
foreach ($domains as $domain) {
$url = \is_array($domain) ? ($domain['url'] ?? null) : null;
if (!\is_string($url)) {
continue;
}

if ('' === rtrim((string) parse_url($url, \PHP_URL_PATH), '/')) {
return;
}

$pathPrefixedUrls[] = $url;
}

if ([] === $pathPrefixedUrls) {
return;
}

$add(
Severity::Warning,
'path_prefixed_domain',
\sprintf(
'Every storefront domain of this channel has a path (%s). UCP requires a business profile at /.well-known/ucp on the root of its origin (RFC 8615, section 3: https://www.rfc-editor.org/rfc/rfc8615.html#section-3), so agents cannot discover this channel and a conformant platform rejects its signed webhooks.',
implode(', ', $pathPrefixedUrls),
),
'Add a domain without a path to the sales channel in the Administration, for example a subdomain.',
);
}

private function checkIdempotency(UcpConfig $config, string $salesChannelId, callable $add): void
{
if (!$config->idempotencyRequired) {
Expand Down
4 changes: 2 additions & 2 deletions src/Ucp/Embedded/EmbeddedResponseListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ public function onKernelRequest(RequestEvent $event): void
return;
}

$config = $this->configService->getConfig($this->domainResolver->resolveByAbsoluteUri($request->getUri())?->salesChannelId);
$config = $this->configService->getConfig($this->domainResolver->resolveByRequest($request)?->salesChannelId);
$origin = $request->headers->get('origin');

if ([] === $config->embeddedAllowedOrigins) {
Expand Down Expand Up @@ -67,7 +67,7 @@ public function onKernelResponse(ResponseEvent $event): void
return;
}

$config = $this->configService->getConfig($this->domainResolver->resolveByAbsoluteUri($request->getUri())?->salesChannelId);
$config = $this->configService->getConfig($this->domainResolver->resolveByRequest($request)?->salesChannelId);
$frameAncestors = [] !== $config->embeddedFrameAncestors ? $config->embeddedFrameAncestors : ["'self'"];
$response = $event->getResponse();
$origin = $request->headers->get('origin');
Expand Down
36 changes: 36 additions & 0 deletions src/Ucp/Http/PathPrefixRestorer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

declare(strict_types=1);

namespace Swag\AgenticCommerce\Ucp\Http;

use Shopware\Core\Framework\Log\Package;
use Swag\AgenticCommerce\Ucp\SalesChannel\SalesChannelDomainResolver;
use Symfony\Component\HttpFoundation\RequestStack;

/**
* The SDK builds its requests from the URI without the domain's path prefix. Only the Symfony
* request it was built from still knows the prefix. That can be the main request while a
* sub-request is current, as for the MCP tools behind `/ucp/mcp`.
*
* @internal
*/
#[Package('framework')]
final class PathPrefixRestorer
{
public function __construct(
private readonly RequestStack $requestStack,
) {
}

public function restore(string $absoluteUri): string
{
foreach ([$this->requestStack->getCurrentRequest(), $this->requestStack->getMainRequest()] as $candidateRequest) {
if (null !== $candidateRequest && $candidateRequest->getUri() === $absoluteUri) {
return SalesChannelDomainResolver::absoluteRequestUri($candidateRequest);
}
}

return $absoluteUri;
}
}
32 changes: 32 additions & 0 deletions src/Ucp/Http/PathPrefixRestoringRequestContextFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

declare(strict_types=1);

namespace Swag\AgenticCommerce\Ucp\Http;

use Shopware\Core\Framework\Log\Package;
use Ucp\Sdk\Model\Http\HttpRequest;
use Ucp\Sdk\Model\RequestContext;
use Ucp\Sdk\Service\HttpRequestContextFactoryInterface;

/** @internal */
#[Package('framework')]
final class PathPrefixRestoringRequestContextFactory implements HttpRequestContextFactoryInterface
{
public function __construct(
private readonly HttpRequestContextFactoryInterface $inner,
private readonly PathPrefixRestorer $pathPrefixRestorer,
) {
}

public function create(HttpRequest $request): RequestContext
{
return $this->inner->create(new HttpRequest(
$request->method,
$this->pathPrefixRestorer->restore($request->absoluteUri),
$request->headers,
$request->query,
$request->body,
));
}
}
Loading