diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f975b4136..c935a84a4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -658,6 +658,7 @@ jobs: adcp storyboard run \ http://acme.localhost:3001/mcp media_buy_seller \ --auth dev-bearer-token-acme-1 \ + --test-kit ../../tests/fixtures/storyboard-test-kit.yaml \ --json --allow-http \ > v3-storyboard-result.json @@ -683,18 +684,69 @@ jobs: " - name: Assert storyboard passed - # Hard gate. The runner's ``overall_status`` MUST be ``passing``; - # any failure fails the job. + # Hard gate. This translator intentionally has no + # ``comply_test_controller``: it exercises a real upstream rather + # than owning the runner's state-control API. SDK 13 therefore grades + # its coverage ``partial`` even when every executable step passes. + # Accept only that exact topology — zero failed steps/tracks, useful + # executed coverage, and no skip class outside controller absence, + # its dependent steps, missing controller tools, or not-applicable + # capabilities. Any real failure or new skip class still blocks CI. run: | python -c " import json, sys, pathlib p = pathlib.Path('examples/v3_reference_seller/v3-storyboard-result.json') with p.open() as f: d = json.load(f) - if d.get('overall_status') != 'passing': + status = d.get('overall_status') + summary = d.get('summary') or {} + skip_counts = summary.get('skipped_by_reason') or {} + allowed_partial_skips = { + 'missing_test_controller', + 'missing_tool', + 'prerequisite_failed', + 'not_applicable', + } + unexpected_skips = sorted(set(skip_counts) - allowed_partial_skips) + steps = [ + step + for track in d.get('tracks', []) + for scenario in track.get('scenarios', []) + for step in scenario.get('steps', []) + ] + unexpected_missing_tools = [ + step.get('task') + for step in steps + if step.get('skip_reason') == 'missing_tool' + and step.get('task') != 'comply_test_controller' + ] + unexpected_prerequisites = [ + step.get('error') + for step in steps + if step.get('skip_reason') == 'prerequisite_failed' + and 'skipped (missing_tool)' not in (step.get('error') or '') + ] + partial_is_expected = ( + status == 'partial' + and summary.get('steps_failed') == 0 + and summary.get('tracks_failed') == 0 + and (summary.get('steps_passed') or 0) > 0 + and (skip_counts.get('missing_test_controller') or 0) > 0 + and not unexpected_skips + and not unexpected_missing_tools + and not unexpected_prerequisites + and d.get('controller_detected') is False + ) + if status != 'passing' and not partial_is_expected: print(json.dumps(d, indent=2)) + if unexpected_skips: + print(f'Unexpected partial skip classes: {unexpected_skips}') + if unexpected_missing_tools: + print(f'Unexpected missing tools: {unexpected_missing_tools}') + if unexpected_prerequisites: + print(f'Unexpected prerequisite skips: {unexpected_prerequisites}') sys.exit(1) - print('Storyboard passing.') + print(f'Storyboard accepted: status={status}, summary={summary}') " - name: Assert upstream traffic (anti-façade gate) diff --git a/MANIFEST.in b/MANIFEST.in index fa6239987..dc2237f85 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,6 +1,7 @@ include ADCP_VERSION include README.md include LICENSE +include MIGRATION*.md recursive-include src/adcp py.typed # Bundled AdCP JSON schemas. ``scripts/bundle_schemas.py`` mirrors # ``schemas/cache/`` into ``src/adcp/_schemas/`` before ``python -m diff --git a/MIGRATION_v6_to_v7.md b/MIGRATION_v6_to_v7.md index e22ea87f8..7519eba53 100644 --- a/MIGRATION_v6_to_v7.md +++ b/MIGRATION_v6_to_v7.md @@ -2,7 +2,7 @@ This guide applies to applications upgrading from any Python SDK 6.x release to 7.x. SDK 7 makes canonical creatives the primary application contract, -updates the bundled protocol schemas to AdCP 3.1.14, and tightens several +updates the bundled protocol schemas to AdCP 3.1.15, and tightens several security and concurrency boundaries. The SDK package version and negotiated AdCP protocol version are independent. @@ -32,6 +32,10 @@ SDK 7 continues to interoperate with AdCP 3.0 and 3.1 agents. waiting for a duplicate completion webhook. 8. Exercise callback validation and multi-tenant isolation in staging before production rollout. +9. Use `WebhookReceiver` for public MCP callback endpoints so RFC 9421 + verification and delivery deduplication happen before application code. + Configure `webhook_secret` on `ADCPClient` only for registrations that + explicitly select the deprecated `HMAC-SHA256` fallback. ## Canonical creatives are the primary API @@ -197,6 +201,12 @@ DNS pinning must provide a custom sender and enforce resolution at connection time. A push-configured handoff with no available delivery transport is now rejected before task creation instead of being accepted and silently dropped. +Public MCP callbacks should enter through `WebhookReceiver`, which verifies the +AdCP RFC 9421 profile, deduplicates delivery, and parses the authenticated raw +body. `ADCPClient.handle_webhook()` is the legacy HMAC convenience path; use it +only when the callback registration explicitly selects `HMAC-SHA256` and the +client is configured with the same `webhook_secret`. + Account registries, sessions, proposals, notification stores, and reference seller state now enforce tenant ownership. Test fixtures or application code that relied on a cross-tenant fallback must be updated to carry the authenticated diff --git a/MIGRATION_v7_to_v8.md b/MIGRATION_v7_to_v8.md new file mode 100644 index 000000000..1c26235eb --- /dev/null +++ b/MIGRATION_v7_to_v8.md @@ -0,0 +1,36 @@ +# Migrating from Python SDK 7 to 8 + +SDK 8 makes the legacy `ADCPClient.handle_webhook()` convenience path fail +closed. Calls without a configured `webhook_secret` no longer accept unsigned +MCP callbacks. + +For AdCP-conformant public endpoints, migrate delivery to `WebhookReceiver`. +It verifies RFC 9421 signatures, deduplicates retries, and parses the +authenticated raw body. Construct it using the +[complete receiver quickstart](README.md#signed-webhooks-adcp-30-receiver-quickstart), +then pass the unchanged request to it: + +```python +outcome = await receiver.receive( + method=request.method, + url=str(request.url), + headers=dict(request.headers), + body=await request.body(), +) +``` + +If a 3.x registration explicitly selects the deprecated `HMAC-SHA256` +fallback, configure the same shared secret on `ADCPClient` and pass the raw +request body to `handle_webhook()`. An endpoint that is isolated from untrusted +networks may temporarily retain unsigned legacy callbacks with +`allow_unauthenticated_webhooks=True`; multi-agent clients must scope this +escape by agent ID. + +## Webhook activity metadata + +`ActivityType.WEBHOOK_RECEIVED` no longer copies the complete callback into +`Activity.metadata["payload"]`. The metadata now contains only `task_id`, +`status`, and `protocol`; `operation_id` and `task_type` remain top-level +activity fields. Update telemetry consumers that read results or tokens from +the old payload field. Process business data from the verified webhook result +instead of exporting it through activity telemetry. diff --git a/README.md b/README.md index abc87df72..3a93ced70 100644 --- a/README.md +++ b/README.md @@ -276,16 +276,16 @@ async with ADCPMultiAgentClient( ## AdCP version support -The 7.x line is built against **AdCP 3.1.14 stable**, makes canonical creatives -the primary Python contract, and negotiates AdCP 3.0, 3.1, and 3.2 wire -dialects. The SDK package version and protocol version are intentionally -independent: +The SDK 8 beta line is built against **AdCP 3.1.15 stable**, makes canonical +creatives the primary Python contract, and negotiates AdCP 3.0, 3.1, and 3.2 +wire dialects. The SDK package version and protocol version are intentionally +independent; AdCP 3.2 beta support will land separately: ```python import adcp -adcp.get_adcp_sdk_version() # SDK package version, e.g. "7.0.0rc1" -adcp.get_adcp_spec_version() # AdCP spec this build targets, e.g. "3.1.14" +adcp.get_adcp_sdk_version() # SDK package version, e.g. "8.0.0b1" +adcp.get_adcp_spec_version() # AdCP spec this build targets, e.g. "3.1.15" ``` If you talk to an agent on a newer spec than this SDK validates, the response @@ -298,7 +298,8 @@ forward traffic degrades gracefully rather than failing. - **[API Reference](https://adcontextprotocol.github.io/adcp-client-python/)** - Complete API documentation with type signatures and examples - **[Protocol Spec](https://github.com/adcontextprotocol/adcp)** - Ad Context Protocol specification - **[Handler authoring](docs/handler-authoring.md)** - Building an AdCP-compliant agent on `adcp.server` -- **[Migrating from SDK 6 to 7](MIGRATION_v6_to_v7.md)** - Breaking API, security, concurrency, and webhook changes +- **[Migrating from SDK 6 to 7](https://github.com/adcontextprotocol/adcp-client-python/blob/main/MIGRATION_v6_to_v7.md)** - Breaking API, security, concurrency, and webhook changes +- **[Migrating from SDK 7 to 8](https://github.com/adcontextprotocol/adcp-client-python/blob/main/MIGRATION_v7_to_v8.md)** - Secure webhook defaults and telemetry changes - **[Testing your AdCP server](docs/testing-your-adcp-server.md)** - In-process harness for unit tests plus storyboard-runner compliance grading - **[Multi-tenant contract](docs/multi-tenant-contract.md)** - Scope invariants every multi-tenant agent must satisfy - **[Examples](examples/)** - Code examples and usage patterns @@ -494,41 +495,53 @@ for result in results: print(f"Async: webhook to {result.submitted.webhook_url}") ``` -### Webhook Handling -Single endpoint handles all webhooks: +### Legacy HMAC webhook handling + +For registrations that explicitly select `HMAC-SHA256`, a single endpoint can +route callbacks through the client helper. Capture the raw body before parsing; +those exact bytes are what the signature authenticates: ```python +import json from fastapi import FastAPI, Request app = FastAPI() @app.post("/webhook/{task_type}/{agent_id}/{operation_id}") async def webhook(task_type: str, agent_id: str, operation_id: str, request: Request): - payload = await request.json() - payload["task_type"] = task_type - payload["operation_id"] = operation_id + raw_body = await request.body() + payload = json.loads(raw_body) # Route to agent client - handlers fire automatically agent = client.agent(agent_id) await agent.handle_webhook( - payload, - request.headers.get("x-adcp-signature") + payload=payload, + task_type=task_type, + operation_id=operation_id, + signature=request.headers.get("x-adcp-signature"), + timestamp=request.headers.get("x-adcp-timestamp"), + raw_body=raw_body, ) return {"received": True} ``` -### Security -Webhook signature verification built-in: +### Legacy HMAC callback verification + +For AdCP 3.x registrations that explicitly select the deprecated +`HMAC-SHA256` authentication mode, shared-secret verification is available on +the client helper: ```python client = ADCPMultiAgentClient( agents=agents, webhook_secret=os.getenv("WEBHOOK_SECRET") ) -# Signatures verified automatically on handle_webhook() +# Legacy HMAC signatures are verified on handle_webhook(). ``` +For the protocol-default RFC 9421 mode, use `WebhookReceiver` as shown below. + ### Signed webhooks (AdCP 3.0): receiver quickstart AdCP 3.0 webhooks are signed under the RFC 9421 profile diff --git a/examples/v3_reference_seller/src/platform.py b/examples/v3_reference_seller/src/platform.py index 56724f9e9..c4d3c6ee1 100644 --- a/examples/v3_reference_seller/src/platform.py +++ b/examples/v3_reference_seller/src/platform.py @@ -130,6 +130,7 @@ ) from adcp.types.legacy import ( LegacyFormat, + LegacyFormatId, ) from adcp.types.legacy import ( LegacyListCreativeFormatsRequest as ListCreativeFormatsRequest, @@ -2019,32 +2020,56 @@ async def list_creative_formats_legacy( format-list endpoint (formats are publisher-defined, baked into the upstream's product catalog). Real adopters drive this from a creative-format registry.""" - del req, ctx + del ctx agent_url = "https://reference.adcp.org" + + def catalog_format(format_id: str, name: str, description: str) -> LegacyFormat: + # The generated FormatReferenceStructuredObject uses AnyUrl, which + # appends a slash to origin-only URLs. Legacy format identity is a + # byte-preserving tuple, so construct the trusted static catalog + # with the SDK's wire-preserving boundary type. + return LegacyFormat.model_construct( + format_id=LegacyFormatId(agent_url=agent_url, id=format_id), + name=name, + description=description, + ) + formats = [ - LegacyFormat.model_validate( - { - "format_id": {"agent_url": agent_url, "id": "display_300x250"}, - "name": "Display 300x250 (medium rectangle)", - "description": "IAB standard 300x250 display banner.", - } + catalog_format( + "display_300x250", + "Display 300x250 (medium rectangle)", + "IAB standard 300x250 display banner.", ), - LegacyFormat.model_validate( - { - "format_id": {"agent_url": agent_url, "id": "display_728x90"}, - "name": "Display 728x90 (leaderboard)", - "description": "IAB standard 728x90 display banner.", - } + catalog_format( + "display_728x90", + "Display 728x90 (leaderboard)", + "IAB standard 728x90 display banner.", ), - LegacyFormat.model_validate( - { - "format_id": {"agent_url": agent_url, "id": "video_16x9_30s"}, - "name": "Video 16:9 30s", - "description": "Standard 30-second 16:9 video creative.", - } + catalog_format( + "video_30s", + "Video 30s", + "Standard 30-second video creative.", + ), + catalog_format( + "video_16x9_30s", + "Video 16:9 30s", + "Standard 30-second 16:9 video creative.", ), ] - self._record("creatives.formats", {}) + if req.format_ids: + requested = { + (str(format_id.agent_url).rstrip("/"), format_id.id) for format_id in req.format_ids + } + formats = [ + format_ + for format_ in formats + if ( + str(format_.format_id.agent_url).rstrip("/"), + format_.format_id.id, + ) + in requested + ] + self._record("creatives.formats", {"format_ids": len(req.format_ids or [])}) return ListCreativeFormatsResponse(formats=formats) # ----- list_creatives -------------------------------------------------- diff --git a/examples/v3_reference_seller/tests/test_smoke_broadening.py b/examples/v3_reference_seller/tests/test_smoke_broadening.py index 29847860c..025db96e6 100644 --- a/examples/v3_reference_seller/tests/test_smoke_broadening.py +++ b/examples/v3_reference_seller/tests/test_smoke_broadening.py @@ -1719,6 +1719,34 @@ async def test_list_creative_formats_is_static_no_upstream_call() -> None: assert respx_mock.calls.call_count == 0 +@pytest.mark.asyncio +async def test_list_creative_formats_filters_requested_ids() -> None: + """The static catalog honors the exact IDs returned by get_products.""" + from adcp.types import LegacyListCreativeFormatsRequest + + platform = _platform_with_upstream() + ctx = _build_ctx() + resp = await platform.list_creative_formats_legacy( + LegacyListCreativeFormatsRequest.model_validate( + { + "format_ids": [ + { + "agent_url": "https://reference.adcp.org/", + "id": "video_30s", + } + ] + } + ), + ctx, + ) + + assert [format_.format_id.id for format_ in resp.formats] == ["video_30s"] + assert resp.model_dump(mode="json")["formats"][0]["format_id"] == { + "agent_url": "https://reference.adcp.org", + "id": "video_30s", + } + + @pytest.mark.asyncio @respx.mock(base_url=_RESPX_BASE_URL) async def test_update_media_buy_rejects_foreign_advertiser_order(respx_mock: Any) -> None: diff --git a/release-please-config.json b/release-please-config.json index cee59b352..81a7d6407 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -4,6 +4,9 @@ "release-type": "python", "package-name": "adcp", "changelog-path": "CHANGELOG.md", + "versioning": "prerelease", + "prerelease-type": "beta", + "prerelease": true, "bump-minor-pre-major": true, "bump-patch-for-minor-pre-major": false, "include-component-in-tag": false diff --git a/scripts/ci/run_storyboard_reference_seller.sh b/scripts/ci/run_storyboard_reference_seller.sh index 98e81c42f..b1fb48062 100755 --- a/scripts/ci/run_storyboard_reference_seller.sh +++ b/scripts/ci/run_storyboard_reference_seller.sh @@ -12,6 +12,7 @@ # PYTHON=python3 # ADCP_PYTHON_VENV=.context/storyboard-reference-seller-venv # ADCP_SDK_ROOT=/path/to/@adcp/sdk +# ADCP_TEST_KIT=tests/fixtures/storyboard-test-kit.yaml # # The script assumes it is running from an adcp-client-python checkout, # installs the Python dependencies needed by examples/seller_agent.py, @@ -32,6 +33,7 @@ ADCP_RUNNER_BIN="${ADCP_RUNNER_BIN:-}" ADCP_SDK_VERSION="${ADCP_SDK_VERSION:-}" ADCP_SDK_TARBALL="${ADCP_SDK_TARBALL:-}" ADCP_SDK_ROOT="${ADCP_SDK_ROOT:-}" +ADCP_TEST_KIT="${ADCP_TEST_KIT:-$ROOT/tests/fixtures/storyboard-test-kit.yaml}" fail() { echo "ERROR: $*" >&2 @@ -194,6 +196,16 @@ trap cleanup EXIT install_or_select_runner install_python_dependencies +STORYBOARD_ARGS=( + storyboard run + "http://127.0.0.1:${ADCP_PORT}/mcp" media_buy_seller + --json --allow-http +) +if "$ADCP_RUNNER_BIN" storyboard run --help | grep -q -- '--test-kit'; then + [[ -f "$ADCP_TEST_KIT" ]] || fail "ADCP_TEST_KIT not found: $ADCP_TEST_KIT" + STORYBOARD_ARGS+=(--test-kit "$ADCP_TEST_KIT") +fi + mkdir -p "$(dirname "$STORYBOARD_RESULT_PATH")" echo "Starting examples/seller_agent.py on port $ADCP_PORT" ADCP_PORT="$ADCP_PORT" "$PYTHON" examples/seller_agent.py >"$SELLER_LOG_PATH" 2>&1 & @@ -202,10 +214,7 @@ wait_for_seller "$SELLER_PID" echo "Running media_buy_seller storyboard" set +e -"$ADCP_RUNNER_BIN" storyboard run \ - "http://127.0.0.1:${ADCP_PORT}/mcp" media_buy_seller \ - --json --allow-http \ - >"$STORYBOARD_RESULT_PATH" +"$ADCP_RUNNER_BIN" "${STORYBOARD_ARGS[@]}" >"$STORYBOARD_RESULT_PATH" RUNNER_STATUS=$? set -e diff --git a/src/adcp/__init__.py b/src/adcp/__init__.py index 37ea5b2db..65f813df5 100644 --- a/src/adcp/__init__.py +++ b/src/adcp/__init__.py @@ -207,13 +207,31 @@ def _resolve_version() -> str: "AdvertiserIndustry", "ArtifactWebhookPayload", "AssetContentType", + "AssetInstance", + "AssetInstanceType", + "AssetVariant", + "AudioContent", "AudienceSource", + "CssContent", + "DaastAsset", + "HtmlContent", + "ImageContent", + "JavascriptContent", + "MarkdownAsset", + "TextContent", + "UrlContent", + "VastAsset", + "VideoContent", + "WebhookContent", "AuthorizationRequiredDetails", "BrandReference", "BrandSource", + "BriefAsset", "BuyingMode", + "CardAsset", "Catalog", "CatalogAction", + "CatalogAsset", "CatalogFieldBinding", "CatalogFieldMapping", "CatalogGroupBinding", @@ -251,6 +269,7 @@ def _resolve_version() -> str: "CreativeStatus", "CreativeVariant", "DateRange", + "DaastTrackerAsset", "DatetimeRange", "DeliveryStatus", "DevicePlatform", @@ -364,6 +383,7 @@ def _resolve_version() -> str: "PackageSignalTargetingGroup", "PackageSignalTargetingGroups", "PaginationRequest", + "PixelTrackerAsset", "Placement", "PlacementReference", "PriceGuidance", @@ -371,6 +391,7 @@ def _resolve_version() -> str: "PricingModel", "ProviderRegistrationTmpxMacro", "Product", + "PublishedPostAsset", "ProductFormatDeclaration", "ProductFilters", "ProductSignalTargetingOption", @@ -432,6 +453,7 @@ def _resolve_version() -> str: "VcpmAuctionPricingOption", "VcpmFixedRatePricingOption", "VcpmPricingOption", + "VastTrackerAsset", "VerifyBrandClaimPayload", "VerifyBrandClaimRequest", "VerifyBrandClaimResponse", @@ -440,6 +462,7 @@ def _resolve_version() -> str: "VerifyBrandClaimSignedSuccessPayload", "VerifyBrandClaimsPayload", "VerifyBrandClaimsRequest", + "ZipAsset", "VerifyBrandClaimsRequestBulk", "VerifyBrandClaimsResponse", "VerifyBrandClaimsResponseBulk", @@ -1014,6 +1037,29 @@ def get_adcp_version() -> str: "upgrade_legacy_format_id", "FormatOptionReference", "AssetContentType", + "AssetInstance", + "AssetInstanceType", + "AssetVariant", + "AudioContent", + "BriefAsset", + "CardAsset", + "CatalogAsset", + "CssContent", + "DaastAsset", + "DaastTrackerAsset", + "HtmlContent", + "ImageContent", + "JavascriptContent", + "MarkdownAsset", + "PixelTrackerAsset", + "PublishedPostAsset", + "TextContent", + "UrlContent", + "VastAsset", + "VastTrackerAsset", + "VideoContent", + "WebhookContent", + "ZipAsset", "Product", "ProductFormatDeclaration", "ProductFilters", @@ -1457,16 +1503,23 @@ def get_adcp_version() -> str: # Creative types ArtifactWebhookPayload, AssetContentType, + AssetInstance, + AssetInstanceType, + AssetVariant, AudienceSource, + AudioContent, AuthorizationRequiredDetails, # Core domain types BrandReference, BrandSource, # Creative Operations + BriefAsset, BuyingMode, + CardAsset, # Catalog types Catalog, CatalogAction, + CatalogAsset, CatalogFieldBinding, CatalogFieldMapping, CatalogGroupBinding, @@ -1502,6 +1555,9 @@ def get_adcp_version() -> str: # Status enums (for control flow) CreativeStatus, CreativeVariant, + CssContent, + DaastAsset, + DaastTrackerAsset, DateRange, DatetimeRange, DeliveryStatus, @@ -1543,9 +1599,12 @@ def get_adcp_version() -> str: GetTaskStatusRequest, GetTaskStatusResponse, Gtin, + HtmlContent, IdentityMatchRequest, IdentityMatchResponse, IdentityMatchTmpxMacro, + ImageContent, + JavascriptContent, KellerType, LegacyBuildCreativeErrorResponse, LegacyBuildCreativeRequest, @@ -1602,6 +1661,7 @@ def get_adcp_version() -> str: # Event Operations LogEventRequest, LogEventResponse, + MarkdownAsset, McpWebhookPayload, MediaBuy, MediaBuyDeliveryStatus, @@ -1622,6 +1682,7 @@ def get_adcp_version() -> str: PackageSignalTargetingGroup, PackageSignalTargetingGroups, PaginationRequest, + PixelTrackerAsset, Placement, PlacementReference, PriceGuidance, @@ -1638,6 +1699,7 @@ def get_adcp_version() -> str: ProvidePerformanceFeedbackRequest, ProvidePerformanceFeedbackResponse, ProviderRegistrationTmpxMacro, + PublishedPostAsset, PushNotificationConfig, Refine, ReportPlanOutcomeRequest, @@ -1674,14 +1736,18 @@ def get_adcp_version() -> str: SyncPlansRequest, SyncPlansResponse, TargetingOverlay, + TextContent, TimeBasedPricingOption, TimeUnit, Transform, UpdateFrequency, UpdateMediaBuyRequest, UpdateMediaBuyResponse, + UrlContent, ValidateInputRequest, ValidateInputResponse, + VastAsset, + VastTrackerAsset, VcpmAuctionPricingOption, VcpmFixedRatePricingOption, VcpmPricingOption, @@ -1698,11 +1764,14 @@ def get_adcp_version() -> str: VerifyBrandClaimsResponseBulk, VerifyBrandClaimsSignedResponse, VerifyBrandClaimsSignedSuccessPayload, + VideoContent, WcagLevel, WebhookChallenge, WebhookChallengeResponse, + WebhookContent, WholesaleFeedEvent, WholesaleFeedWebhook, + ZipAsset, aliases, ) from adcp.types import _generated as generated diff --git a/src/adcp/client.py b/src/adcp/client.py index 5cc84cf35..24fe00641 100644 --- a/src/adcp/client.py +++ b/src/adcp/client.py @@ -10,7 +10,7 @@ import os import time import warnings -from collections.abc import Callable, Iterator +from collections.abc import Callable, Iterator, Mapping from datetime import datetime, timezone from typing import TYPE_CHECKING, Any, TypedDict, cast from uuid import uuid4 @@ -362,6 +362,9 @@ "update_media_buy", } ) +_LEGACY_ONLY_CREATIVE_TASKS = frozenset( + {"build_creative", "list_creative_formats", "preview_creative"} +) class Checkpoint(TypedDict): @@ -438,6 +441,7 @@ def __init__( server_version: str | None = None, legacy_format_converter: LegacyFormatConverter | None = None, canonical_format_legacy_resolver: CanonicalFormatLegacyResolver | None = None, + allow_unauthenticated_webhooks: bool = False, ): """ Initialize ADCP client for a single agent. @@ -446,7 +450,15 @@ def __init__( agent_config: Agent configuration webhook_url_template: Template for webhook URLs with {agent_id}, {task_type}, {operation_id} - webhook_secret: Secret for webhook signature verification + webhook_secret: Shared secret for the deprecated HMAC-SHA256 webhook + fallback. Configure this only when the registration explicitly + selected legacy HMAC; conformant public endpoints should use + :class:`adcp.webhooks.WebhookReceiver` for RFC 9421 verification. + allow_unauthenticated_webhooks: Explicit compatibility escape for + accepting unsigned MCP webhooks when ``webhook_secret`` is not + configured. Defaults to False so public webhook receivers fail + closed. Only enable this for endpoints that cannot be reached + from an untrusted network. A2A webhook handling is unaffected. on_activity: Callback for activity events webhook_timestamp_tolerance: Maximum age (in seconds) for webhook timestamps. Webhooks with timestamps older than this or more than @@ -572,9 +584,13 @@ def __init__( """ self._adcp_version: str = resolve_adcp_version(adcp_version) self._server_version: str | None = _resolve_server_version(server_version) + if type(allow_unauthenticated_webhooks) is not bool: + raise TypeError("allow_unauthenticated_webhooks must be a bool") + self.agent_config = agent_config self.webhook_url_template = webhook_url_template self.webhook_secret = webhook_secret + self.allow_unauthenticated_webhooks = allow_unauthenticated_webhooks self.on_activity = on_activity self.webhook_timestamp_tolerance = webhook_timestamp_tolerance self.capabilities_ttl = capabilities_ttl @@ -4744,8 +4760,8 @@ def _verify_webhook_signature( ``raw_body`` is missing — fails closed per spec). """ if not self.webhook_secret: - logger.warning("Webhook signature verification skipped: no webhook_secret configured") - return True + logger.error("Webhook signature verification failed: no webhook_secret configured") + return False # Fail closed per adcontextprotocol/adcp#2478: verifiers that cannot # capture raw bytes MUST reject, surfacing the infrastructure gap @@ -5053,9 +5069,12 @@ async def _handle_mcp_webhook( payload: Webhook payload dict task_type: Task type from application routing operation_id: Operation identifier from application routing - signature: Optional HMAC-SHA256 signature for verification (X-AdCP-Signature header) - timestamp: Optional Unix timestamp for signature verification (X-AdCP-Timestamp header) - raw_body: Optional raw HTTP request body for signature verification + signature: HMAC-SHA256 signature from X-AdCP-Signature. Required + when ``webhook_secret`` configures the deprecated HMAC fallback. + timestamp: Unix timestamp from X-AdCP-Timestamp. Required with the + deprecated HMAC fallback. + raw_body: Raw HTTP request body. Required with the deprecated HMAC + fallback so the authenticated bytes are the bytes processed. Returns: TaskResult with parsed task-specific response data @@ -5066,7 +5085,10 @@ async def _handle_mcp_webhook( """ from adcp.types.generated_poc.core.mcp_webhook_payload import McpWebhookPayload - # When a webhook_secret is configured, require signed webhooks + # Signed MCP webhooks are the secure default. Receiving without a + # verifier requires an explicit compatibility opt-in so a missing + # secret cannot silently turn a public endpoint into an unauthenticated + # callback receiver. if self.webhook_secret: if not signature or not timestamp: raise ADCPWebhookSignatureError( @@ -5077,24 +5099,58 @@ async def _handle_mcp_webhook( f"Webhook signature verification failed for agent {self.agent_config.id}" ) raise ADCPWebhookSignatureError("Invalid webhook signature") + if raw_body is None: # Defensive type narrowing; verifier rejects this above. + raise ADCPWebhookSignatureError("Signed webhook raw body is required") + try: + authenticated_payload = json.loads(raw_body) + except (TypeError, ValueError, UnicodeDecodeError) as exc: + raise ADCPWebhookSignatureError("Invalid signed webhook body") from exc + if not isinstance(authenticated_payload, dict): + raise ADCPWebhookSignatureError("Signed webhook body must be a JSON object") + # Process the bytes that were authenticated, not a separately + # supplied parsed object that middleware could have transformed. + payload = cast(dict[str, Any], authenticated_payload) + elif self.allow_unauthenticated_webhooks is not True: + raise ADCPWebhookSignatureError( + "MCP webhook cannot be authenticated because webhook_secret is not configured; " + "use WebhookReceiver for RFC 9421 callbacks, configure a shared secret only for " + "an explicitly selected legacy HMAC registration, or set " + "allow_unauthenticated_webhooks=True only for receivers isolated from " + "untrusted networks" + ) + + # Select the canonical/legacy surface from the body only after signed + # callbacks have been replaced by their authenticated raw bytes. + payload_task_type = payload.get("task_type") + if not preserve_legacy_identity and payload_task_type in _LEGACY_ONLY_CREATIVE_TASKS: + raise ValueError( + f"{payload_task_type} webhook payloads carry legacy creative identity; use " + "handle_webhook_legacy()" + ) # Validate and parse MCP webhook payload webhook = McpWebhookPayload.model_validate(payload) + authenticated_task_type = webhook.task_type.value + authenticated_operation_id = webhook.operation_id or operation_id + + if preserve_legacy_identity: + if authenticated_task_type not in _LEGACY_CREATIVE_TASKS: + raise ValueError( + f"{authenticated_task_type} is not a legacy-only callback; use " + "handle_webhook()" + ) # Emit activity for monitoring self._emit_activity( Activity( type=ActivityType.WEBHOOK_RECEIVED, - operation_id=operation_id, + operation_id=authenticated_operation_id, agent_id=self.agent_config.id, - task_type=task_type, + task_type=authenticated_task_type, timestamp=datetime.now(timezone.utc).isoformat(), metadata={ - "payload": ( - payload - if preserve_legacy_identity - else strip_legacy_creative_identity(payload) - ), + "task_id": webhook.task_id, + "status": webhook.status.value, "protocol": "mcp", }, ) @@ -5103,8 +5159,8 @@ async def _handle_mcp_webhook( # Extract fields and parse result return self._parse_webhook_result( task_id=webhook.task_id, - task_type=task_type, - operation_id=operation_id, + task_type=authenticated_task_type, + operation_id=authenticated_operation_id, status=webhook.status, result=webhook.result, timestamp=webhook.timestamp, @@ -5296,7 +5352,8 @@ async def handle_webhook( This method provides a unified interface for handling webhooks from both MCP and A2A protocols: - - MCP Webhooks: HTTP POST with dict payload, optional HMAC signature + - MCP Webhooks: HTTP POST with dict payload; the deprecated HMAC fallback + requires a signature, timestamp, and raw body - A2A Webhooks: Task or TaskStatusUpdateEvent objects based on status The method automatically detects the protocol type and routes to the @@ -5309,19 +5366,20 @@ async def handle_webhook( - Task: A2A webhook for terminated statuses (completed, failed) - TaskStatusUpdateEvent: A2A webhook for intermediate statuses (working, input-required, submitted) - task_type: Task type from application routing (e.g., "get_products"). - Applications should extract this from URL routing pattern: - /webhook/{task_type}/{agent_id}/{operation_id} - operation_id: Operation identifier from application routing. - Used to correlate webhook notifications with original task submission. - signature: Optional HMAC-SHA256 signature for MCP webhook verification - (X-AdCP-Signature header). Ignored for A2A webhooks. - timestamp: Optional Unix timestamp (seconds) for MCP webhook signature - verification (X-AdCP-Timestamp header). Required when signature is provided. - raw_body: Optional raw HTTP request body bytes for signature verification. - When provided, used directly instead of re-serializing the payload, - avoiding cross-language JSON serialization mismatches. Strongly - recommended for production use. + task_type: Task type from application routing for A2A callbacks. For + MCP callbacks, the validated payload's authenticated ``task_type`` + controls parsing and activity correlation. + operation_id: Operation identifier from application routing. For MCP + callbacks, the authenticated payload value controls correlation + when present; this argument is a compatibility fallback for old + payloads that omit it. + signature: HMAC-SHA256 signature from X-AdCP-Signature. Required when + ``webhook_secret`` configures the deprecated HMAC fallback and + ignored for A2A callbacks. + timestamp: Unix timestamp from X-AdCP-Timestamp. Required with the + deprecated HMAC fallback and ignored for A2A callbacks. + raw_body: Raw HTTP request body captured before JSON parsing. Required + with the deprecated HMAC fallback and ignored for A2A callbacks. Returns: TaskResult with parsed task-specific response data. The structure @@ -5332,9 +5390,10 @@ async def handle_webhook( ValidationError: If MCP payload doesn't match WebhookPayload schema Note: - task_type and operation_id were deprecated from the webhook payload - per AdCP specification. Applications must extract these from URL - routing and pass them explicitly. + AdCP-conformant public MCP endpoints should use + :class:`adcp.webhooks.WebhookReceiver`, which verifies RFC 9421, + deduplicates retries, and parses the authenticated body. This method's + HMAC mode exists only for explicitly selected legacy registrations. Examples: MCP webhook (HTTP endpoint): @@ -5373,7 +5432,10 @@ async def handle_webhook( >>> if result.status == GeneratedTaskStatus.working: >>> print(f"Task still working: {result.metadata.get('message')}") """ - if task_type in {"build_creative", "list_creative_formats", "preview_creative"}: + if ( + isinstance(payload, (Task, TaskStatusUpdateEvent)) + and task_type in _LEGACY_ONLY_CREATIVE_TASKS + ): raise ValueError( f"{task_type} webhook payloads carry legacy creative identity; use " "handle_webhook_legacy()" @@ -5401,7 +5463,9 @@ async def handle_webhook_legacy( ) -> TaskResult[AdcpAsyncResponseData]: """Parse a callback for a task whose protocol shape is explicitly legacy-only.""" - if task_type not in _LEGACY_CREATIVE_TASKS: + if isinstance(payload, (Task, TaskStatusUpdateEvent)) and task_type not in ( + _LEGACY_CREATIVE_TASKS + ): raise ValueError(f"{task_type} is not a legacy-only callback; use handle_webhook()") self._warn_legacy_creative_api("handle_webhook_legacy") return await self._dispatch_webhook( @@ -5463,6 +5527,7 @@ def __init__( adcp_version: str | dict[str, str] | None = None, legacy_format_converter: LegacyFormatConverter | None = None, canonical_format_legacy_resolver: CanonicalFormatLegacyResolver | None = None, + allow_unauthenticated_webhooks: bool | Mapping[str, bool] = False, ): """ Initialize multi-agent client. @@ -5470,12 +5535,18 @@ def __init__( Args: agents: List of agent configurations webhook_url_template: Template for webhook URLs - webhook_secret: Secret for webhook verification + webhook_secret: Shared secret for the deprecated HMAC-SHA256 webhook + fallback. Configure only for registrations that explicitly + selected legacy HMAC; use ``WebhookReceiver`` for RFC 9421. on_activity: Callback for activity events handlers: Task completion handlers signing: Optional RFC 9421 signing config forwarded to every per-agent ADCPClient. The same identity signs traffic to all agents. See ADCPClient.__init__ for details. + allow_unauthenticated_webhooks: Explicit compatibility escape. + A mapping scopes the opt-in by agent ID; omitted IDs remain + protected. A uniform True is accepted only for a single-agent + collection. Defaults to False. adcp_version: AdCP protocol release pin. Three forms: - ``None`` (default): every per-agent ADCPClient resolves @@ -5491,6 +5562,31 @@ def __init__( See ADCPClient.__init__ for per-instance semantics. Cross-major pins raise ConfigurationError at construction. """ + agent_ids = {agent.id for agent in agents} + if isinstance(allow_unauthenticated_webhooks, Mapping): + unknown_ids = set(allow_unauthenticated_webhooks) - agent_ids + if unknown_ids: + unknown = ", ".join(sorted(unknown_ids)) + raise ValueError( + "allow_unauthenticated_webhooks contains unknown agent IDs: " + unknown + ) + if any(type(value) is not bool for value in allow_unauthenticated_webhooks.values()): + raise TypeError("allow_unauthenticated_webhooks mapping values must be bools") + per_agent_unauthenticated = dict(allow_unauthenticated_webhooks) + else: + if type(allow_unauthenticated_webhooks) is not bool: + raise TypeError( + "allow_unauthenticated_webhooks must be a bool or mapping of agent IDs to bools" + ) + if allow_unauthenticated_webhooks is True and len(agents) > 1: + raise ValueError( + "allow_unauthenticated_webhooks=True cannot be applied to multiple agents; " + "pass a mapping keyed by the isolated agent IDs" + ) + per_agent_unauthenticated = { + agent.id: allow_unauthenticated_webhooks for agent in agents + } + # Per-agent map → resolve each pin individually for the dict form; # otherwise use the uniform pin for all agents. if isinstance(adcp_version, dict): @@ -5509,6 +5605,7 @@ def __init__( adcp_version=self._per_agent_versions.get(agent.id, default_pin), legacy_format_converter=legacy_format_converter, canonical_format_legacy_resolver=canonical_format_legacy_resolver, + allow_unauthenticated_webhooks=per_agent_unauthenticated.get(agent.id, False), ) for agent in agents } @@ -5525,6 +5622,7 @@ def __init__( adcp_version=self._adcp_version, legacy_format_converter=legacy_format_converter, canonical_format_legacy_resolver=canonical_format_legacy_resolver, + allow_unauthenticated_webhooks=per_agent_unauthenticated.get(agent.id, False), ) for agent in agents } diff --git a/src/adcp/exceptions.py b/src/adcp/exceptions.py index ddb3a177e..ed725fd91 100644 --- a/src/adcp/exceptions.py +++ b/src/adcp/exceptions.py @@ -188,9 +188,20 @@ def __init__( class RegistryError(ADCPError): """Error from AdCP registry API operations (brand/property lookups).""" - def __init__(self, message: str, status_code: int | None = None): + def __init__( + self, + message: str, + status_code: int | None = None, + *, + method: str | None = None, + retry_after_seconds: float | None = None, + details: dict[str, Any] | None = None, + ): """Initialize registry error.""" self.status_code = status_code + self.method = method + self.retry_after_seconds = retry_after_seconds + self.details = details suggestion = "Check that the registry API is accessible and the domain is valid." super().__init__(message, suggestion=suggestion) diff --git a/src/adcp/registry.py b/src/adcp/registry.py index d50aa35c9..025a8a92b 100644 --- a/src/adcp/registry.py +++ b/src/adcp/registry.py @@ -3,7 +3,11 @@ from __future__ import annotations import asyncio +import json +import math import re +from datetime import datetime, timezone +from email.utils import parsedate_to_datetime from typing import Any, TypeVar, cast from urllib.parse import quote as url_quote @@ -41,10 +45,101 @@ DEFAULT_REGISTRY_URL = "https://agenticadvertising.org" MAX_BULK_DOMAINS = 100 MAX_BULK_POLICIES = 100 +MAX_REGISTRY_ERROR_DETAILS_BYTES = 64 * 1024 +MAX_RETRY_AFTER_SECONDS = 2_147_483.647 _COMMUNITY_MIRROR_PLATFORM_RE = re.compile(r"^[a-z0-9_-]{1,64}$") +def _bounded_retry_seconds(value: Any, *, scale: float = 1.0) -> float | None: + """Normalize a non-negative numeric retry hint to bounded seconds.""" + if isinstance(value, bool) or not isinstance(value, (int, float, str)): + return None + try: + if isinstance(value, str): + if not value.isascii() or not value.isdigit(): + return None + numeric: int | float = int(value) + else: + numeric = value + if isinstance(numeric, float) and not math.isfinite(numeric): + return None + if numeric < 0: + return None + if numeric > MAX_RETRY_AFTER_SECONDS / scale: + return MAX_RETRY_AFTER_SECONDS + seconds = float(numeric) * scale + except (TypeError, ValueError, OverflowError): + return None + if not math.isfinite(seconds): + return None + return seconds + + +def _retry_after_seconds( + response: httpx.Response, details: dict[str, Any] | None = None +) -> float | None: + """Parse a header retry hint, then fall back to common JSON spellings.""" + value = response.headers.get("retry-after") + if isinstance(value, str) and value.strip(): + value = value.strip() + seconds = _bounded_retry_seconds(value) + if seconds is not None: + return seconds + try: + retry_at = parsedate_to_datetime(value) + except (TypeError, ValueError, OverflowError): + pass + else: + if retry_at.tzinfo is None: + retry_at = retry_at.replace(tzinfo=timezone.utc) + seconds = (retry_at - datetime.now(timezone.utc)).total_seconds() + if math.isfinite(seconds): + return min(MAX_RETRY_AFTER_SECONDS, max(0.0, seconds)) + + if details is None: + return None + for key, scale in (("retryAfterMs", 0.001), ("retryAfter", 1.0), ("retry_after", 1.0)): + seconds = _bounded_retry_seconds(details.get(key), scale=scale) + if seconds is not None: + return seconds + return None + + +def _registry_error_details(response: httpx.Response) -> dict[str, Any] | None: + """Return a bounded JSON object from a registry error response.""" + content = response.content + if isinstance(content, bytes) and len(content) > MAX_REGISTRY_ERROR_DETAILS_BYTES: + return None + try: + details = response.json() + if not isinstance(details, dict): + return None + encoded = json.dumps(details, ensure_ascii=False, default=str).encode("utf-8") + except (TypeError, ValueError): + return None + if len(encoded) > MAX_REGISTRY_ERROR_DETAILS_BYTES: + return None + return cast(dict[str, Any], details) + + +def _registry_http_error( + response: httpx.Response, + *, + method: str, + operation: str, +) -> RegistryError: + """Build a structured error without exposing an unbounded response body.""" + details = _registry_error_details(response) + return RegistryError( + f"{operation} failed: HTTP {response.status_code}", + status_code=response.status_code, + method=method.upper(), + retry_after_seconds=_retry_after_seconds(response, details), + details=details, + ) + + def _normalize_community_mirror_platform(platform: str) -> str: """Trim, lowercase, and validate a community mirror platform key.""" normalized = platform.strip().lower() if isinstance(platform, str) else "" @@ -200,9 +295,10 @@ async def _request( if allow_404 and response.status_code == 404: return None if response.status_code not in expected: - raise RegistryError( - f"{operation} failed: HTTP {response.status_code}", - status_code=response.status_code, + raise _registry_http_error( + response, + method=method, + operation=operation, ) return response except RegistryError: @@ -283,7 +379,7 @@ def _parse(model_cls: type[_T], data: Any, operation: str) -> _T: except (ValidationError, ValueError) as e: raise RegistryError(f"{operation} failed: invalid response: {e}") from e - async def lookup_brand(self, domain: str) -> ResolvedBrand | None: + async def lookup_brand(self, domain: str, *, fresh: bool = False) -> ResolvedBrand | None: """Resolve a domain to its brand identity. Works for any domain — brand houses, sub-brands, and operators @@ -291,6 +387,9 @@ async def lookup_brand(self, domain: str) -> ResolvedBrand | None: Args: domain: Domain to resolve (e.g., "nike.com", "wpp.com"). + fresh: Request a live origin check instead of a cached registry + result. Defaults to False. Use for authorization decisions + that require current brand relationship evidence. Returns: ResolvedBrand if found, None if not in the registry. @@ -301,21 +400,19 @@ async def lookup_brand(self, domain: str) -> ResolvedBrand | None: Example: brand = await registry.lookup_brand(request.brand.domain) """ - client = await self._get_client() try: - response = await client.get( - f"{self._base_url}/api/brands/resolve", - params={"domain": domain}, - headers={"User-Agent": self._user_agent}, - timeout=self._timeout, + params = {"domain": domain} + if fresh: + params["fresh"] = "true" + response = await self._request( + "GET", + "/api/brands/resolve", + params=params, + operation="Brand lookup", + allow_404=True, ) - if response.status_code == 404: + if response is None: return None - if response.status_code != 200: - raise RegistryError( - f"Brand lookup failed: HTTP {response.status_code}", - status_code=response.status_code, - ) data = response.json() if data is None: return None @@ -370,9 +467,10 @@ async def _lookup_brands_chunk(self, domains: list[str]) -> dict[str, ResolvedBr timeout=self._timeout, ) if response.status_code != 200: - raise RegistryError( - f"Bulk brand lookup failed: HTTP {response.status_code}", - status_code=response.status_code, + raise _registry_http_error( + response, + method="POST", + operation="Bulk brand lookup", ) data = response.json() results_raw = data.get("results", {}) @@ -413,9 +511,10 @@ async def lookup_property(self, domain: str) -> ResolvedProperty | None: if response.status_code == 404: return None if response.status_code != 200: - raise RegistryError( - f"Property lookup failed: HTTP {response.status_code}", - status_code=response.status_code, + raise _registry_http_error( + response, + method="GET", + operation="Property lookup", ) data = response.json() if data is None: @@ -473,9 +572,10 @@ async def _lookup_properties_chunk( timeout=self._timeout, ) if response.status_code != 200: - raise RegistryError( - f"Bulk property lookup failed: HTTP {response.status_code}", - status_code=response.status_code, + raise _registry_http_error( + response, + method="POST", + operation="Bulk property lookup", ) data = response.json() results_raw = data.get("results", {}) @@ -517,9 +617,10 @@ async def list_members(self, limit: int = 100) -> list[Member]: timeout=self._timeout, ) if response.status_code != 200: - raise RegistryError( - f"Member list failed: HTTP {response.status_code}", - status_code=response.status_code, + raise _registry_http_error( + response, + method="GET", + operation="Member list", ) data = response.json() return [Member.model_validate(m) for m in data.get("members", [])] @@ -557,9 +658,10 @@ async def get_member(self, slug: str) -> Member | None: if response.status_code == 404: return None if response.status_code != 200: - raise RegistryError( - f"Member lookup failed: HTTP {response.status_code}", - status_code=response.status_code, + raise _registry_http_error( + response, + method="GET", + operation="Member lookup", ) data = response.json() if data is None: @@ -630,9 +732,10 @@ async def list_policies( timeout=self._timeout, ) if response.status_code != 200: - raise RegistryError( - f"Policy list failed: HTTP {response.status_code}", - status_code=response.status_code, + raise _registry_http_error( + response, + method="GET", + operation="Policy list", ) data = response.json() return [PolicySummary.model_validate(p) for p in data.get("policies", [])] @@ -677,9 +780,10 @@ async def resolve_policy( if response.status_code == 404: return None if response.status_code != 200: - raise RegistryError( - f"Policy resolve failed: HTTP {response.status_code}", - status_code=response.status_code, + raise _registry_http_error( + response, + method="GET", + operation="Policy resolve", ) data = response.json() if data is None: @@ -739,9 +843,10 @@ async def _resolve_policies_chunk(self, policy_ids: list[str]) -> dict[str, Poli timeout=self._timeout, ) if response.status_code != 200: - raise RegistryError( - f"Bulk policy resolve failed: HTTP {response.status_code}", - status_code=response.status_code, + raise _registry_http_error( + response, + method="POST", + operation="Bulk policy resolve", ) data = response.json() results_raw = data.get("results", {}) @@ -789,9 +894,10 @@ async def policy_history( if response.status_code == 404: return None if response.status_code != 200: - raise RegistryError( - f"Policy history failed: HTTP {response.status_code}", - status_code=response.status_code, + raise _registry_http_error( + response, + method="GET", + operation="Policy history", ) data = response.json() if data is None: @@ -900,9 +1006,10 @@ async def save_policy( timeout=self._timeout, ) if response.status_code != 200: - raise RegistryError( - f"Policy save failed: HTTP {response.status_code}", - status_code=response.status_code, + raise _registry_http_error( + response, + method="POST", + operation="Policy save", ) result: dict[str, Any] = response.json() return result diff --git a/src/adcp/types/__init__.py b/src/adcp/types/__init__.py index 1e3e13df4..1afc70874 100644 --- a/src/adcp/types/__init__.py +++ b/src/adcp/types/__init__.py @@ -350,11 +350,24 @@ "CanonicalFormatVastVideo", "CanonicalProjectionReference", "CanonicalSlotOverride", + "AssetInstance", + "AssetInstanceType", + "AssetVariant", + "BriefAsset", + "CardAsset", + "CatalogAsset", + "DaastAsset", + "DaastTrackerAsset", + "MarkdownAsset", "FormatIdParameter", "FormatOptionReference", "PixelTrackerAsset", "PixelTrackerEvent", "PixelTrackerMethod", + "PublishedPostAsset", + "VastAsset", + "VastTrackerAsset", + "ZipAsset", "Recovery", "Source", "ProductFormatDeclaration", @@ -1037,7 +1050,10 @@ def __dir__() -> list[str]: ArtifactWebhookPayload, Asset, AssetContentType, + AssetInstance, + AssetInstanceType, AssetType, + AssetVariant, AssignedPackage, Assignments, AudienceSource, @@ -1061,6 +1077,7 @@ def __dir__() -> list[str]: BothPreviewRender, BrandReference, BrandSource, + BriefAsset, BriefFormatAsset, BuildCreativeCreative, BusinessEntity, @@ -1095,8 +1112,10 @@ def __dir__() -> list[str]: CapabilitiesCreative, CapabilitiesMediaBuy, Capability, + CardAsset, Catalog, CatalogAction, + CatalogAsset, CatalogFieldBinding, CatalogFieldBinding1, CatalogFieldMapping, @@ -1174,8 +1193,10 @@ def __dir__() -> list[str]: CssContent, CssFormatAsset, CssFormatGroupAsset, + DaastAsset, DaastFormatAsset, DaastFormatGroupAsset, + DaastTrackerAsset, DaastTrackingEvent, DaastVersion, DailyBreakdownItem, @@ -1402,6 +1423,7 @@ def __dir__() -> list[str]: LogEventResponse1, LogEventSuccessResponse, Logo, + MarkdownAsset, MarkdownFlavor, MarkdownFormatAsset, MarkdownFormatGroupAsset, @@ -1500,6 +1522,7 @@ def __dir__() -> list[str]: ProvidePerformanceFeedbackResponse1, ProvidePerformanceFeedbackSuccessResponse, ProviderRegistrationTmpxMacro, + PublishedPostAsset, PublisherDomain, PublisherIdentifierTypes, PublisherProperties, @@ -1689,8 +1712,10 @@ def __dir__() -> list[str]: ValidateInputRequest, ValidateInputResponse, ValidationMode, + VastAsset, VastFormatAsset, VastFormatGroupAsset, + VastTrackerAsset, VastTrackingEvent, VastVersion, VcpmAuctionPricingOption, @@ -1726,6 +1751,7 @@ def __dir__() -> list[str]: WholesaleFeedEvent, WholesaleFeedSignal, WholesaleFeedWebhook, + ZipAsset, project_geo_postal_areas, to_account_response, ) diff --git a/src/adcp/types/_eager.py b/src/adcp/types/_eager.py index e7934e076..af9c34e68 100644 --- a/src/adcp/types/_eager.py +++ b/src/adcp/types/_eager.py @@ -444,6 +444,9 @@ ActivateSignalSuccessResponse, AgentDeployment, AgentDestination, + AssetInstance, + AssetInstanceType, + AssetVariant, AudioFormatAsset, AudioFormatGroupAsset, AuthorizedAgent, @@ -454,6 +457,7 @@ AuthorizedAgentsBySignalId, AuthorizedAgentsBySignalTag, BothPreviewRender, + BriefAsset, BriefFormatAsset, # Cross-module name collision aliases (#911, Step 2) BuildCreativeCreative, @@ -481,6 +485,8 @@ CapabilitiesAccount, CapabilitiesCreative, CapabilitiesMediaBuy, + CardAsset, + CatalogAsset, CatalogFormatAsset, CatalogGroupBinding, ComplyErrorResponse, @@ -500,8 +506,10 @@ CreateMediaBuyAuthentication, CssFormatAsset, CssFormatGroupAsset, + DaastAsset, DaastFormatAsset, DaastFormatGroupAsset, + DaastTrackerAsset, DeliveryCreative, Deployment, Destination, @@ -569,6 +577,7 @@ LogEventErrorResponse, LogEventResponse1, LogEventSuccessResponse, + MarkdownAsset, MarkdownFormatAsset, MarkdownFormatGroupAsset, MediaBuyDeliveryStatus, @@ -590,6 +599,7 @@ ProvidePerformanceFeedbackResponse1, ProvidePerformanceFeedbackSuccessResponse, ProviderRegistrationTmpxMacro, + PublishedPostAsset, PublisherProperties, PublisherPropertiesAll, PublisherPropertiesById, @@ -658,14 +668,17 @@ ValidateContentDeliveryErrorResponse, ValidateContentDeliveryResponse1, ValidateContentDeliverySuccessResponse, + VastAsset, VastFormatAsset, VastFormatGroupAsset, + VastTrackerAsset, VehicleUnit, VideoFormatAsset, VideoFormatGroupAsset, WebhookFormatAsset, WebhookFormatGroupAsset, WholesaleFeedSignal, + ZipAsset, ) from adcp.types.legacy import ( LegacyBuildCreativeErrorResponse, @@ -958,6 +971,9 @@ def __init__(self, *args: object, **kwargs: object) -> None: "ArtifactWebhookPayload", "Asset", "AssetContentType", + "AssetInstance", + "AssetInstanceType", + "AssetVariant", "AssetType", "AssignedPackage", "Assignments", @@ -982,6 +998,7 @@ def __init__(self, *args: object, **kwargs: object) -> None: "BothPreviewRender", "BrandReference", "BrandSource", + "BriefAsset", "BriefFormatAsset", "BuildCreativeCreative", "BusinessEntity", @@ -1012,12 +1029,14 @@ def __init__(self, *args: object, **kwargs: object) -> None: "CanonicalFormatVastVideo", "CanonicalProjectionReference", "CanonicalSlotOverride", + "CardAsset", "CapabilitiesAccount", "CapabilitiesCreative", "CapabilitiesMediaBuy", "Capability", "Catalog", "CatalogAction", + "CatalogAsset", "CatalogFieldBinding", "CatalogFieldBinding1", "CatalogFieldMapping", @@ -1095,8 +1114,10 @@ def __init__(self, *args: object, **kwargs: object) -> None: "CssContent", "CssFormatAsset", "CssFormatGroupAsset", + "DaastAsset", "DaastFormatAsset", "DaastFormatGroupAsset", + "DaastTrackerAsset", "DaastTrackingEvent", "DaastVersion", "DailyBreakdownItem", @@ -1325,6 +1346,7 @@ def __init__(self, *args: object, **kwargs: object) -> None: "LogEventSuccessResponse", "Logo", "MEDIA_BUY_LEGACY_STATUS_VALUES", + "MarkdownAsset", "MarkdownFlavor", "MarkdownFormatAsset", "MarkdownFormatGroupAsset", @@ -1401,6 +1423,7 @@ def __init__(self, *args: object, **kwargs: object) -> None: "ProductFormatSellerPreference", "ProductSignalTargetingOption", "ProviderRegistrationTmpxMacro", + "PublishedPostAsset", "Property", "PropertyId", "PropertyIdActivationKey", @@ -1613,14 +1636,17 @@ def __init__(self, *args: object, **kwargs: object) -> None: "ValidateInputRequest", "ValidateInputResponse", "ValidationMode", + "VastAsset", "VastFormatAsset", "VastFormatGroupAsset", "VastTrackingEvent", "VastVersion", + "VastTrackerAsset", "VcpmAuctionPricingOption", "VcpmFixedRatePricingOption", "VcpmPricingOption", "VehicleUnit", + "ZipAsset", "VenueBreakdownItem", "VerifyBrandClaimPayload", "VerifyBrandClaimRequest", diff --git a/src/adcp/types/aliases.py b/src/adcp/types/aliases.py index 3d9ad43da..95ff0efb8 100644 --- a/src/adcp/types/aliases.py +++ b/src/adcp/types/aliases.py @@ -34,7 +34,7 @@ from __future__ import annotations from typing import Annotated as _Annotated -from typing import Any, TypeAlias +from typing import Any, Literal, TypeAlias from pydantic import ConfigDict, Discriminator, Tag @@ -279,6 +279,79 @@ def _generated_alias(name: str, fallback_name: str) -> Any: # No more separate reference type needed # Import Package from _generated (still uses qualified name for internal reasons) from adcp.types._generated import _PackageFromPackage as Package +from adcp.types._generated import ( + AudioAsset, + AssetVariant, + BriefAsset, + CardAsset, + CatalogAsset, + CssAsset, + DaastAsset, + DaastTrackerAsset, + HtmlAsset, + ImageAsset, + JavascriptAsset, + MarkdownAsset, + PixelTrackerAsset, + PublishedPostAsset, + TextAsset, + UrlAsset, + VastAsset, + VastTrackerAsset, + VideoAsset, + WebhookAsset, + ZipAsset, +) + +# Match the adopter-facing discriminated union used by the JavaScript SDK. +# ``AssetVariant`` remains the generated Pydantic RootModel used for runtime +# validation; ``AssetInstance`` is the inner union so type checkers retain and +# narrow its concrete model members instead of collapsing through ``Any``. +AssetInstance: TypeAlias = _Annotated[ + ImageAsset + | VideoAsset + | AudioAsset + | TextAsset + | HtmlAsset + | UrlAsset + | CssAsset + | JavascriptAsset + | MarkdownAsset + | VastAsset + | DaastAsset + | BriefAsset + | CatalogAsset + | WebhookAsset + | ZipAsset + | PublishedPostAsset + | CardAsset + | PixelTrackerAsset + | VastTrackerAsset + | DaastTrackerAsset, + Discriminator("asset_type"), +] +AssetInstanceType: TypeAlias = Literal[ + "image", + "video", + "audio", + "text", + "html", + "url", + "css", + "javascript", + "markdown", + "vast", + "daast", + "brief", + "catalog", + "webhook", + "zip", + "published_post", + "card", + "pixel_tracker", + "vast_tracker", + "daast_tracker", +] # ``ProductFormatDeclaration`` comes from ``adcp.types.canonical_decl`` # (a hand-rolled class) rather than ``generated_poc`` because the codegen @@ -290,9 +363,6 @@ def _generated_alias(name: str, fallback_name: str) -> Any: from adcp.types.generated_poc.core.assets.pixel_tracker_asset import ( Method as PixelTrackerMethod, ) -from adcp.types.generated_poc.core.assets.pixel_tracker_asset import ( - PixelTrackerAsset, -) # ---------------------------------------------------------------------------- # Canonical-formats public surface (AdCP 3.1) @@ -2135,9 +2205,22 @@ class UnknownGroupAsset(_BaseGroupAsset): "CanonicalFormatVastVideo", "CanonicalProjectionReference", "CanonicalSlotOverride", + "AssetInstance", + "AssetInstanceType", + "AssetVariant", + "BriefAsset", + "CardAsset", + "CatalogAsset", + "DaastAsset", + "DaastTrackerAsset", + "MarkdownAsset", "PixelTrackerAsset", "PixelTrackerEvent", "PixelTrackerMethod", + "PublishedPostAsset", + "VastAsset", + "VastTrackerAsset", + "ZipAsset", # Error envelope sub-enums (for SDK advisory construction) "Recovery", "Source", diff --git a/src/adcp/types/creative.py b/src/adcp/types/creative.py index cb0f3d05f..ed965e5b6 100644 --- a/src/adcp/types/creative.py +++ b/src/adcp/types/creative.py @@ -63,12 +63,29 @@ "RepeatableAssetGroup", "Asset", "AssetContentType", + "AssetInstance", + "AssetInstanceType", + "AssetVariant", + "MarkdownAsset", + "VastAsset", + "DaastAsset", + "BriefAsset", + "CatalogAsset", + "ZipAsset", + "PublishedPostAsset", + "CardAsset", + "PixelTrackerAsset", + "VastTrackerAsset", + "DaastTrackerAsset", "ImageContent", "VideoContent", "AudioContent", + "CssContent", "HtmlContent", + "JavascriptContent", "TextContent", "UrlContent", + "WebhookContent", "Dimensions", "Responsive", "GetCreativeFeaturesRequest", @@ -92,7 +109,13 @@ from adcp.types import ( # noqa: F401 Asset, AssetContentType, + AssetInstance, + AssetInstanceType, + AssetVariant, AudioContent, + BriefAsset, + CardAsset, + CatalogAsset, Creative, CreativeAgent, CreativeApproval, @@ -104,6 +127,9 @@ CreativePolicy, CreativeStatus, CreativeVariant, + CssContent, + DaastAsset, + DaastTrackerAsset, Dimensions, Format, FormatAssetUnion, @@ -114,6 +140,7 @@ GroupFormatAssetUnion, HtmlContent, ImageContent, + JavascriptContent, LegacyBuildCreativeErrorResponse, LegacyBuildCreativeRequest, LegacyBuildCreativeResponse, @@ -129,7 +156,10 @@ LegacyPreviewCreativeVariantResponse, ListCreativesRequest, ListCreativesResponse, + MarkdownAsset, + PixelTrackerAsset, PreviewRender, + PublishedPostAsset, Renders, RepeatableAssetGroup, Responsive, @@ -140,5 +170,9 @@ SyncCreativesSuccessResponse, TextContent, UrlContent, + VastAsset, + VastTrackerAsset, VideoContent, + WebhookContent, + ZipAsset, ) diff --git a/tests/fixtures/public_api_snapshot.json b/tests/fixtures/public_api_snapshot.json index 07693010d..33bda6b73 100644 --- a/tests/fixtures/public_api_snapshot.json +++ b/tests/fixtures/public_api_snapshot.json @@ -52,7 +52,11 @@ "AgentStats", "ArtifactWebhookPayload", "AssetContentType", + "AssetInstance", + "AssetInstanceType", + "AssetVariant", "AudienceSource", + "AudioContent", "AuthorizationContext", "AuthorizationRequiredDetails", "AuthorizedAgent", @@ -67,13 +71,16 @@ "BrandReference", "BrandRegistryItem", "BrandSource", + "BriefAsset", "BuyingMode", "CREATIVE_AGENT_CONFIG", "CalibrateContentErrorResponse", "CalibrateContentResponse1", "CalibrateContentSuccessResponse", + "CardAsset", "Catalog", "CatalogAction", + "CatalogAsset", "CatalogFieldBinding", "CatalogFieldMapping", "CatalogGroupBinding", @@ -117,7 +124,10 @@ "CreativeManifest", "CreativeStatus", "CreativeVariant", + "CssContent", "CursorStore", + "DaastAsset", + "DaastTrackerAsset", "DateRange", "DatetimeRange", "DeliveryStatus", @@ -204,6 +214,7 @@ "GetTaskStatusRequest", "GetTaskStatusResponse", "Gtin", + "HtmlContent", "HtmlPreviewRender", "IdempotencyConflictError", "IdempotencyExpiredError", @@ -212,8 +223,10 @@ "IdentityMatchRequest", "IdentityMatchResponse", "IdentityMatchTmpxMacro", + "ImageContent", "InlineDaastAsset", "InlineVastAsset", + "JavascriptContent", "KellerType", "KeyValueActivationKey", "LegacyBuildCreativeErrorResponse", @@ -276,6 +289,7 @@ "LogEventResponse", "LogEventResponse1", "LogEventSuccessResponse", + "MarkdownAsset", "McpWebhookPayload", "MediaBuy", "MediaBuyDeliveryStatus", @@ -296,6 +310,7 @@ "PackageSignalTargetingGroup", "PackageSignalTargetingGroups", "PaginationRequest", + "PixelTrackerAsset", "Placement", "PlacementReference", "PlatformDeployment", @@ -334,6 +349,7 @@ "ProvidePerformanceFeedbackResponse1", "ProvidePerformanceFeedbackSuccessResponse", "ProviderRegistrationTmpxMacro", + "PublishedPostAsset", "PublisherDivergence", "PublisherProperties", "PublisherPropertiesAll", @@ -414,6 +430,7 @@ "TargetingOverlay", "TaskResult", "TaskStatus", + "TextContent", "TimeBasedPricingOption", "TimeUnit", "Transform", @@ -431,6 +448,7 @@ "UpdateMediaBuyResponse3", "UpdateMediaBuySubmittedResponse", "UpdateMediaBuySuccessResponse", + "UrlContent", "UrlDaastAsset", "UrlPreviewRender", "UrlVastAsset", @@ -445,6 +463,8 @@ "ValidationMode", "ValidationOutcome", "ValidationResult", + "VastAsset", + "VastTrackerAsset", "VcpmAuctionPricingOption", "VcpmFixedRatePricingOption", "VcpmPricingOption", @@ -461,11 +481,13 @@ "VerifyBrandClaimsResponseBulk", "VerifyBrandClaimsSignedResponse", "VerifyBrandClaimsSignedSuccessPayload", + "VideoContent", "WcagLevel", "WebhookChallenge", "WebhookChallengeError", "WebhookChallengeResponse", "WebhookChallengeResult", + "WebhookContent", "WebhookDedupStore", "WebhookDestinationPolicy", "WebhookMetadata", @@ -475,6 +497,7 @@ "WebhookVerifyOptions", "WholesaleFeedEvent", "WholesaleFeedWebhook", + "ZipAsset", "aliases", "challenge_webhook_destination", "create_a2a_webhook_payload", @@ -566,7 +589,10 @@ "ArtifactWebhookPayload", "Asset", "AssetContentType", + "AssetInstance", + "AssetInstanceType", "AssetType", + "AssetVariant", "AssignedPackage", "Assignments", "AudienceSource", @@ -590,6 +616,7 @@ "BothPreviewRender", "BrandReference", "BrandSource", + "BriefAsset", "BriefFormatAsset", "BuildCreativeCreative", "BusinessEntity", @@ -624,8 +651,10 @@ "CapabilitiesCreative", "CapabilitiesMediaBuy", "Capability", + "CardAsset", "Catalog", "CatalogAction", + "CatalogAsset", "CatalogFieldBinding", "CatalogFieldBinding1", "CatalogFieldMapping", @@ -703,8 +732,10 @@ "CssContent", "CssFormatAsset", "CssFormatGroupAsset", + "DaastAsset", "DaastFormatAsset", "DaastFormatGroupAsset", + "DaastTrackerAsset", "DaastTrackingEvent", "DaastVersion", "DailyBreakdownItem", @@ -931,6 +962,7 @@ "LogEventResponse1", "LogEventSuccessResponse", "Logo", + "MarkdownAsset", "MarkdownFlavor", "MarkdownFormatAsset", "MarkdownFormatGroupAsset", @@ -1029,6 +1061,7 @@ "ProvidePerformanceFeedbackResponse1", "ProvidePerformanceFeedbackSuccessResponse", "ProviderRegistrationTmpxMacro", + "PublishedPostAsset", "PublisherDomain", "PublisherIdentifierTypes", "PublisherProperties", @@ -1218,8 +1251,10 @@ "ValidateInputRequest", "ValidateInputResponse", "ValidationMode", + "VastAsset", "VastFormatAsset", "VastFormatGroupAsset", + "VastTrackerAsset", "VastTrackingEvent", "VastVersion", "VcpmAuctionPricingOption", @@ -1257,6 +1292,7 @@ "WholesaleFeedSignal", "WholesaleFeedWebhook", "WholesaleFeedWebhook", + "ZipAsset", "aliases", "buyer", "creative", diff --git a/tests/fixtures/storyboard-test-kit.yaml b/tests/fixtures/storyboard-test-kit.yaml new file mode 100644 index 000000000..424c3dd9c --- /dev/null +++ b/tests/fixtures/storyboard-test-kit.yaml @@ -0,0 +1,21 @@ +# Deterministic creative inputs for @adcp/sdk storyboard runs. The runner +# selects the first fixture satisfying the seller-declared slot constraints; +# these URLs are opaque test values and are never fetched by the examples. +assets: + images: + - url: https://fixtures.example.com/display-300x250.png + width: 300 + height: 250 + mime_type: image/png + - url: https://fixtures.example.com/display-970x250.png + width: 970 + height: 250 + mime_type: image/png + text: + headlines: + - Test campaign headline + descriptions: + - Deterministic storyboard creative description. + cta: + - Learn more + click_url: https://fixtures.example.com/landing-page diff --git a/tests/test_legacy_only_creative_surfaces.py b/tests/test_legacy_only_creative_surfaces.py index 19b0c30d9..717208a8b 100644 --- a/tests/test_legacy_only_creative_surfaces.py +++ b/tests/test_legacy_only_creative_surfaces.py @@ -117,27 +117,44 @@ async def test_generic_primary_execution_rejects_legacy_only_tasks(task_name: st ) async def test_generic_primary_webhook_rejects_legacy_only_tasks(task_name: str) -> None: client = ADCPClient( - AgentConfig(id="creative", agent_uri="https://creative.example", protocol=Protocol.MCP) + AgentConfig(id="creative", agent_uri="https://creative.example", protocol=Protocol.MCP), + allow_unauthenticated_webhooks=True, ) + payload = { + "idempotency_key": "webhook-legacy-only", + "task_id": "task-legacy-only", + "task_type": task_name, + "status": "working", + "timestamp": "2026-07-29T00:00:00Z", + } with pytest.raises(ValueError, match="handle_webhook_legacy"): - await client.handle_webhook({}, task_name, "operation-1") + await client.handle_webhook(payload, "get_signals", "operation-1") @pytest.mark.asyncio async def test_legacy_webhook_entrypoint_rejects_noncreative_tasks() -> None: client = ADCPClient( - AgentConfig(id="creative", agent_uri="https://creative.example", protocol=Protocol.MCP) + AgentConfig(id="creative", agent_uri="https://creative.example", protocol=Protocol.MCP), + allow_unauthenticated_webhooks=True, ) + payload = { + "idempotency_key": "webhook-noncreative", + "task_id": "task-noncreative", + "task_type": "get_signals", + "status": "working", + "timestamp": "2026-07-29T00:00:00Z", + } with pytest.raises(ValueError, match="handle_webhook"): - await client.handle_webhook_legacy({}, "get_signals", "operation-1") + await client.handle_webhook_legacy(payload, "get_products", "operation-1") @pytest.mark.asyncio async def test_legacy_webhook_accepts_projectable_creative_tasks() -> None: client = ADCPClient( - AgentConfig(id="creative", agent_uri="https://creative.example", protocol=Protocol.MCP) + AgentConfig(id="creative", agent_uri="https://creative.example", protocol=Protocol.MCP), + allow_unauthenticated_webhooks=True, ) payload = { "idempotency_key": "webhook-event-legacy", @@ -171,7 +188,8 @@ def _completed_legacy_result() -> dict[str, object]: @pytest.mark.asyncio async def test_legacy_mcp_webhook_preserves_completed_identity() -> None: client = ADCPClient( - AgentConfig(id="creative", agent_uri="https://creative.example", protocol=Protocol.MCP) + AgentConfig(id="creative", agent_uri="https://creative.example", protocol=Protocol.MCP), + allow_unauthenticated_webhooks=True, ) payload = { "idempotency_key": "webhook-event-completed-legacy", @@ -229,6 +247,7 @@ async def test_primary_webhook_sanitizes_every_status_and_activity(status: str) client = ADCPClient( AgentConfig(id="creative", agent_uri="https://creative.example", protocol=Protocol.MCP), on_activity=activities.append, + allow_unauthenticated_webhooks=True, ) payload = { "idempotency_key": "webhook-event-0001", diff --git a/tests/test_public_api.py b/tests/test_public_api.py index 7c8c8e989..43acc86db 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -6,6 +6,8 @@ from __future__ import annotations +import pytest + def test_core_domain_types_are_exported(): """Core domain types are accessible from main package.""" @@ -29,6 +31,57 @@ def test_core_domain_types_are_exported(): assert hasattr(adcp, type_name), f"{type_name} not exported from adcp package" +def test_complete_asset_union_is_exported(): + """Every registry-backed asset variant is available on stable import paths.""" + from typing import get_args + + from pydantic import TypeAdapter, ValidationError + + import adcp + from adcp import types + from adcp.types import creative + + asset_types = [ + "ImageContent", + "VideoContent", + "AudioContent", + "TextContent", + "HtmlContent", + "UrlContent", + "CssContent", + "JavascriptContent", + "MarkdownAsset", + "VastAsset", + "DaastAsset", + "BriefAsset", + "CatalogAsset", + "WebhookContent", + "ZipAsset", + "PublishedPostAsset", + "CardAsset", + "PixelTrackerAsset", + "VastTrackerAsset", + "DaastTrackerAsset", + ] + for type_name in asset_types: + assert hasattr(adcp, type_name), f"{type_name} not exported from adcp" + assert hasattr(types, type_name), f"{type_name} not exported from adcp.types" + assert hasattr(creative, type_name), f"{type_name} not exported from adcp.types.creative" + + assert adcp.AssetVariant is types.AssetVariant + assert adcp.AssetInstance is types.AssetInstance + assert adcp.AssetInstanceType is types.AssetInstanceType + + union = get_args(types.AssetInstance)[0] + assert set(get_args(union)) == {getattr(types, type_name) for type_name in asset_types} + + adapter = TypeAdapter(types.AssetInstance) + with pytest.raises(ValidationError, match="union_tag_invalid"): + adapter.validate_python({"asset_type": "not_registered"}) + with pytest.raises(ValidationError, match="union_tag_not_found"): + adapter.validate_python({}) + + def test_wholesale_feed_notification_types_are_stably_exported(): """AdCP 3.1 catalog webhook types stay on stable import paths.""" import adcp diff --git a/tests/test_registry.py b/tests/test_registry.py index 586b92b84..04873c84c 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -184,6 +184,125 @@ async def test_sends_correct_params(self): timeout=10.0, ) + @pytest.mark.asyncio + async def test_fresh_lookup_requests_live_origin_check(self): + mock_client = MagicMock() + mock_client.get = AsyncMock(return_value=_mock_response(404)) + + rc = RegistryClient(client=mock_client) + await rc.lookup_brand("nike.com", fresh=True) + + assert mock_client.get.call_args.kwargs["params"] == { + "domain": "nike.com", + "fresh": "true", + } + + @pytest.mark.asyncio + async def test_http_error_exposes_bounded_recovery_metadata(self): + response = _mock_response(429, {"code": "RATE_LIMITED", "retry_after": 17}) + response.headers = {"retry-after": "17"} + mock_client = MagicMock() + mock_client.get = AsyncMock(return_value=response) + + rc = RegistryClient(client=mock_client) + with pytest.raises(RegistryError) as exc_info: + await rc.lookup_brand("nike.com") + + error = exc_info.value + assert error.status_code == 429 + assert error.method == "GET" + assert error.retry_after_seconds == 17 + assert error.details == {"code": "RATE_LIMITED", "retry_after": 17} + + @pytest.mark.asyncio + @pytest.mark.parametrize( + ("details", "expected"), + [ + ({"retryAfterMs": 2500}, 2.5), + ({"retryAfter": 12}, 12.0), + ({"retry_after": 17}, 17.0), + ], + ) + async def test_http_error_uses_body_retry_hint_without_header(self, details, expected): + response = _mock_response(429, details) + response.headers = {} + mock_client = MagicMock() + mock_client.get = AsyncMock(return_value=response) + + rc = RegistryClient(client=mock_client) + with pytest.raises(RegistryError) as exc_info: + await rc.lookup_brand("nike.com") + + assert exc_info.value.retry_after_seconds == expected + + @pytest.mark.asyncio + async def test_http_error_retry_after_header_takes_precedence(self): + response = _mock_response(429, {"retryAfterMs": 2500, "retry_after": 17}) + response.headers = {"retry-after": "9"} + mock_client = MagicMock() + mock_client.get = AsyncMock(return_value=response) + + rc = RegistryClient(client=mock_client) + with pytest.raises(RegistryError) as exc_info: + await rc.lookup_brand("nike.com") + + assert exc_info.value.retry_after_seconds == 9 + + @pytest.mark.asyncio + @pytest.mark.parametrize("key", ["retryAfterMs", "retryAfter", "retry_after"]) + async def test_http_error_clamps_unrepresentable_body_retry_hint(self, key): + response = _mock_response(429, {key: 10**1000}) + response.headers = {} + mock_client = MagicMock() + mock_client.get = AsyncMock(return_value=response) + + rc = RegistryClient(client=mock_client) + with pytest.raises(RegistryError) as exc_info: + await rc.lookup_brand("nike.com") + + assert exc_info.value.retry_after_seconds == 2_147_483.647 + + @pytest.mark.asyncio + async def test_http_error_ignores_negative_unrepresentable_body_retry_hint(self): + response = _mock_response(429, {"retryAfter": -(10**1000)}) + response.headers = {} + mock_client = MagicMock() + mock_client.get = AsyncMock(return_value=response) + + rc = RegistryClient(client=mock_client) + with pytest.raises(RegistryError) as exc_info: + await rc.lookup_brand("nike.com") + + assert exc_info.value.retry_after_seconds is None + + @pytest.mark.asyncio + async def test_http_error_clamps_huge_retry_after_header(self): + response = _mock_response(429) + response.headers = {"retry-after": "9" * 1000} + mock_client = MagicMock() + mock_client.get = AsyncMock(return_value=response) + + rc = RegistryClient(client=mock_client) + with pytest.raises(RegistryError) as exc_info: + await rc.lookup_brand("nike.com") + + assert exc_info.value.retry_after_seconds == 2_147_483.647 + + @pytest.mark.asyncio + async def test_http_error_drops_oversized_details(self): + response = httpx.Response( + 500, + json={"message": "x" * (64 * 1024)}, + ) + mock_client = MagicMock() + mock_client.get = AsyncMock(return_value=response) + + rc = RegistryClient(client=mock_client) + with pytest.raises(RegistryError) as exc_info: + await rc.lookup_brand("nike.com") + + assert exc_info.value.details is None + @pytest.mark.asyncio async def test_returns_none_for_null_body(self): mock_client = MagicMock() diff --git a/tests/test_release_configuration.py b/tests/test_release_configuration.py index f503f82ff..567842e72 100644 --- a/tests/test_release_configuration.py +++ b/tests/test_release_configuration.py @@ -1,4 +1,4 @@ -"""Release automation is the single source of truth for package versions.""" +"""Release automation is the single source of truth for the SDK 8 beta line.""" from __future__ import annotations @@ -23,9 +23,9 @@ def test_worktree_version_matches_normalized_release_manifest() -> None: assert project_section.group(1) == pep440_prerelease(manifest["."]) -def test_release_please_targets_stable_versions() -> None: +def test_release_please_targets_sdk_8_beta_from_breaking_commit() -> None: config = json.loads((ROOT / "release-please-config.json").read_text()) package = config["packages"]["."] - assert "versioning" not in package - assert "prerelease-type" not in package - assert "prerelease" not in package + assert package["versioning"] == "prerelease" + assert package["prerelease-type"] == "beta" + assert package["prerelease"] is True diff --git a/tests/test_webhook_handling.py b/tests/test_webhook_handling.py index 39184b3eb..dd0ae512c 100644 --- a/tests/test_webhook_handling.py +++ b/tests/test_webhook_handling.py @@ -13,7 +13,7 @@ from google.protobuf.json_format import MessageToDict as _MessageToDict from pydantic import BaseModel -from adcp.client import ADCPClient +from adcp.client import ADCPClient, ADCPMultiAgentClient from adcp.exceptions import ADCPWebhookSignatureError from adcp.types import GeneratedTaskStatus from adcp.types.core import AgentConfig, Protocol, TaskStatus @@ -47,7 +47,30 @@ def setup_method(self): agent_uri="https://test.example.com", protocol=Protocol.MCP, ) - self.client = ADCPClient(self.config) + self.client = ADCPClient(self.config, allow_unauthenticated_webhooks=True) + + def test_unauthenticated_escape_requires_literal_bool(self): + with pytest.raises(TypeError, match="must be a bool"): + ADCPClient(self.config, allow_unauthenticated_webhooks="false") # type: ignore[arg-type] + + def test_multi_agent_unauthenticated_escape_is_scoped(self): + second = AgentConfig( + id="second_agent", + agent_uri="https://second.example.com", + protocol=Protocol.MCP, + ) + client = ADCPMultiAgentClient( + [self.config, second], + allow_unauthenticated_webhooks={self.config.id: True}, + ) + assert client.agent(self.config.id).allow_unauthenticated_webhooks is True + assert client.agent(second.id).allow_unauthenticated_webhooks is False + + with pytest.raises(ValueError, match="cannot be applied to multiple agents"): + ADCPMultiAgentClient( + [self.config, second], + allow_unauthenticated_webhooks=True, + ) @pytest.mark.asyncio async def test_mcp_webhook_completed_success(self): @@ -245,6 +268,122 @@ async def test_mcp_webhook_signature_verification_with_raw_body(self): assert result.status == TaskStatus.COMPLETED + @pytest.mark.asyncio + async def test_mcp_webhook_processes_the_authenticated_raw_body(self): + """A separately parsed payload cannot replace the signed content.""" + import hashlib + import hmac + + supplied_payload = { + "idempotency_key": "whk_supplied_payload", + "task_id": "task_supplied", + "task_type": "create_media_buy", + "status": "completed", + "timestamp": "2025-01-15T10:00:00Z", + "result": {"media_buy_id": "mb_wrong", "buyer_ref": "ref_wrong", "packages": []}, + } + authenticated_payload = { + **supplied_payload, + "idempotency_key": "whk_authenticated_payload", + "task_id": "task_authenticated", + "result": { + "media_buy_id": "mb_authenticated", + "buyer_ref": "ref_authenticated", + "packages": [], + }, + } + raw_body = json.dumps(authenticated_payload, separators=(",", ":")) + header_timestamp = str(int(time.time())) + signature = hmac.new( + b"test_secret", + f"{header_timestamp}.{raw_body}".encode(), + hashlib.sha256, + ).hexdigest() + + client = ADCPClient(self.config, webhook_secret="test_secret") + result = await client.handle_webhook( + supplied_payload, + task_type="create_media_buy", + operation_id="op_authenticated", + signature=signature, + timestamp=header_timestamp, + raw_body=raw_body, + ) + + assert result.metadata["task_id"] == "task_authenticated" + + @pytest.mark.asyncio + async def test_mcp_webhook_uses_authenticated_routing_fields(self): + """Signed body fields, not URL arguments, control correlation and parsing.""" + import hashlib + import hmac + + activities = [] + authenticated_payload = { + "idempotency_key": "whk_authenticated_route", + "operation_id": "op_from_body", + "task_id": "task_authenticated_route", + "task_type": "activate_signal", + "status": "working", + "timestamp": "2025-01-15T10:00:00Z", + } + raw_body = json.dumps(authenticated_payload, separators=(",", ":")) + header_timestamp = str(int(time.time())) + signature = hmac.new( + b"test_secret", + f"{header_timestamp}.{raw_body}".encode(), + hashlib.sha256, + ).hexdigest() + + client = ADCPClient( + self.config, + webhook_secret="test_secret", + on_activity=activities.append, + ) + result = await client.handle_webhook( + {**authenticated_payload, "operation_id": "op_untrusted"}, + task_type="get_signals", + operation_id="op_from_url", + signature=signature, + timestamp=header_timestamp, + raw_body=raw_body, + ) + + assert result.metadata["operation_id"] == "op_from_body" + assert activities[-1].operation_id == "op_from_body" + assert activities[-1].task_type == "activate_signal" + + @pytest.mark.asyncio + async def test_mcp_webhook_activity_does_not_expose_payload_or_token(self): + activities = [] + client = ADCPClient( + self.config, + allow_unauthenticated_webhooks=True, + on_activity=activities.append, + ) + payload = { + "idempotency_key": "whk_activity_payload", + "task_id": "task_activity", + "task_type": "create_media_buy", + "status": "completed", + "timestamp": "2025-01-15T10:00:00Z", + "token": "secret-token-value", + "result": { + "media_buy_id": "mb_sensitive", + "buyer_ref": "ref_sensitive", + "packages": [], + }, + } + + await client.handle_webhook(payload, "create_media_buy", "op_activity") + + activity = activities[-1] + assert activity.metadata == { + "task_id": "task_activity", + "status": "completed", + "protocol": "mcp", + } + @pytest.mark.asyncio async def test_mcp_webhook_signature_verification_invalid(self): """Test signature verification with invalid HMAC.""" @@ -390,6 +529,37 @@ async def test_mcp_webhook_missing_headers_with_secret_rejects(self): timestamp=None, ) + @pytest.mark.asyncio + async def test_mcp_webhook_without_verifier_fails_closed(self): + """A missing secret must not silently accept an unsigned MCP callback.""" + client = ADCPClient(self.config) + payload = { + "idempotency_key": "whk_test-closedxxxx", + "task_id": "test-closed", + "timestamp": "2024-01-01T00:00:00Z", + "status": "completed", + "result": {"products": []}, + } + + with pytest.raises(ADCPWebhookSignatureError, match="cannot be authenticated"): + await client.handle_webhook(payload, "get_products", "op-closed") + + @pytest.mark.asyncio + async def test_mcp_webhook_explicit_unauthenticated_escape(self): + """Isolated legacy receivers can deliberately retain unsigned callbacks.""" + client = ADCPClient(self.config, allow_unauthenticated_webhooks=True) + payload = { + "idempotency_key": "whk_test-escape-xxxx", + "task_id": "test-escape", + "task_type": "create_media_buy", + "timestamp": "2024-01-01T00:00:00Z", + "status": "completed", + "result": {"media_buy_id": "mb_escape", "buyer_ref": "ref_escape", "packages": []}, + } + + result = await client.handle_webhook(payload, "create_media_buy", "op-escape") + assert result.success is True + @pytest.mark.asyncio async def test_mcp_webhook_missing_required_fields(self): """Test MCP webhook with missing required fields.""" @@ -801,7 +971,10 @@ def setup_method(self): agent_uri="https://a2a.example.com", protocol=Protocol.A2A, ) - self.mcp_client = ADCPClient(self.mcp_config) + self.mcp_client = ADCPClient( + self.mcp_config, + allow_unauthenticated_webhooks=True, + ) self.a2a_client = ADCPClient(self.a2a_config) @pytest.mark.asyncio diff --git a/tests/type_checks/asset_instance_narrowing.py b/tests/type_checks/asset_instance_narrowing.py new file mode 100644 index 000000000..54efbb050 --- /dev/null +++ b/tests/type_checks/asset_instance_narrowing.py @@ -0,0 +1,21 @@ +"""Adopter contract for the public creative-asset discriminated union.""" + +from typing_extensions import assert_type + +from adcp.types import AssetInstance, AssetInstanceType, ImageContent, VideoContent + + +def dimensions(asset: AssetInstance) -> tuple[int, int] | None: + # Mypy narrows Pydantic model unions by runtime class. The companion + # AssetInstanceType alias supplies the exhaustive discriminator values. + if isinstance(asset, ImageContent): + assert_type(asset, ImageContent) + return asset.width, asset.height + if isinstance(asset, VideoContent): + assert_type(asset, VideoContent) + return asset.width, asset.height + return None + + +def accepts_discriminator(asset_type: AssetInstanceType) -> AssetInstanceType: + return asset_type