From 813d7fd30049fa8291ed01937a816e4993372a3a Mon Sep 17 00:00:00 2001 From: Oleksandr Halushchak Date: Wed, 15 Jul 2026 13:53:52 +0200 Subject: [PATCH 01/11] New module: adcontextprotocol/tmp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Prebid Server module that acts as an OpenRTB → Trusted Match Protocol (TMP) adapter and TMP router. For each auction the module: - Resolves site.domain / app.bundle to a property_rid via the AdCP property registry (adcp-go), with an in-memory expirable LRU cache. - Builds a TMP context_match_request from site/app/imp/geo fields, and, when identity tokens are available on user.eids, a companion identity_match_request. The two payloads are structurally separated per the TMP privacy contract. - Fans out to one or more configured TMP providers in parallel, signing every outbound request with Ed25519 per the spec (X-AdCP-Signature, X-AdCP-Key-Id) and binding the signature to the provider endpoint URL and daily epoch. - Merges responses locally (offers intersected with eligibility) and surfaces the joined signals on the bid response ext under a configurable key, with optional mirroring to prebid.targeting. Multi-provider configuration is supported: each provider can serve identity_url, context_url, or both. At least one URL per provider is required. Existing modules/scope3/rtd is left untouched. Wire types, canonicalization, and signing primitives are imported from github.com/adcontextprotocol/adcp-go; the OpenRTB→TMP mapping and the property registry client are implemented here. --- go.mod | 25 +- go.sum | 79 +++--- modules/adcontextprotocol/tmp/README.md | 159 ++++++++++++ modules/adcontextprotocol/tmp/adapter.go | 214 +++++++++++++++++ modules/adcontextprotocol/tmp/adapter_test.go | 111 +++++++++ modules/adcontextprotocol/tmp/config.go | 189 +++++++++++++++ modules/adcontextprotocol/tmp/config_test.go | 115 +++++++++ modules/adcontextprotocol/tmp/hooks.go | 138 +++++++++++ modules/adcontextprotocol/tmp/masking.go | 61 +++++ modules/adcontextprotocol/tmp/module.go | 78 ++++++ .../tmp/property_registry.go | 227 ++++++++++++++++++ .../tmp/property_registry_test.go | 151 ++++++++++++ .../adcontextprotocol/tmp/provider_client.go | 96 ++++++++ modules/adcontextprotocol/tmp/router.go | 195 +++++++++++++++ modules/adcontextprotocol/tmp/router_test.go | 216 +++++++++++++++++ modules/builder.go | 4 + 16 files changed, 2018 insertions(+), 40 deletions(-) create mode 100644 modules/adcontextprotocol/tmp/README.md create mode 100644 modules/adcontextprotocol/tmp/adapter.go create mode 100644 modules/adcontextprotocol/tmp/adapter_test.go create mode 100644 modules/adcontextprotocol/tmp/config.go create mode 100644 modules/adcontextprotocol/tmp/config_test.go create mode 100644 modules/adcontextprotocol/tmp/hooks.go create mode 100644 modules/adcontextprotocol/tmp/masking.go create mode 100644 modules/adcontextprotocol/tmp/module.go create mode 100644 modules/adcontextprotocol/tmp/property_registry.go create mode 100644 modules/adcontextprotocol/tmp/property_registry_test.go create mode 100644 modules/adcontextprotocol/tmp/provider_client.go create mode 100644 modules/adcontextprotocol/tmp/router.go create mode 100644 modules/adcontextprotocol/tmp/router_test.go diff --git a/go.mod b/go.mod index 363683694e4..9e39857345e 100644 --- a/go.mod +++ b/go.mod @@ -8,13 +8,14 @@ require ( github.com/IABTechLab/adscert v0.34.0 github.com/NYTimes/gziphandler v1.1.1 github.com/WURFL/golang-wurfl v1.30.3 + github.com/adcontextprotocol/adcp-go v0.0.0-20260703103742-c8f541ba6888 github.com/alitto/pond v1.8.3 github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d github.com/benbjohnson/clock v1.3.0 github.com/buger/jsonparser v1.1.2 github.com/chasex/glog v0.0.0-20160217080310-c62392af379c github.com/coocood/freecache v1.2.1 - github.com/docker/go-units v0.4.0 + github.com/docker/go-units v0.5.0 github.com/go-sql-driver/mysql v1.6.0 github.com/gofrs/uuid v4.2.0+incompatible github.com/golang/glog v1.2.5 @@ -28,13 +29,13 @@ require ( github.com/prebid/go-gdpr v1.12.0 github.com/prebid/go-gpp v0.2.0 github.com/prebid/openrtb/v20 v20.3.0 - github.com/prometheus/client_golang v1.12.1 - github.com/prometheus/client_model v0.2.0 + github.com/prometheus/client_golang v1.23.2 + github.com/prometheus/client_model v0.6.2 github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 github.com/rs/cors v1.11.0 github.com/spf13/cast v1.5.0 github.com/spf13/viper v1.12.0 - github.com/stretchr/testify v1.8.4 + github.com/stretchr/testify v1.11.1 github.com/tidwall/gjson v1.17.1 github.com/tidwall/sjson v1.2.5 github.com/vrischmann/go-metrics-influxdb v0.1.1 @@ -52,24 +53,24 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/fsnotify/fsnotify v1.5.4 // indirect - github.com/golang/protobuf v1.5.4 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/influxdata/influxdb1-client v0.0.0-20191209144304-8bf82d3c094d // indirect - github.com/magiconair/properties v1.8.6 // indirect - github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect + github.com/klauspost/cpuid/v2 v2.2.10 // indirect + github.com/magiconair/properties v1.8.10 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pelletier/go-toml v1.9.5 // indirect github.com/pelletier/go-toml/v2 v2.0.1 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/prometheus/common v0.32.1 // indirect - github.com/prometheus/procfs v0.7.3 // indirect + github.com/prometheus/common v0.67.5 // indirect + github.com/prometheus/procfs v0.20.1 // indirect github.com/sergi/go-diff v1.2.0 // indirect github.com/spf13/afero v1.8.2 // indirect github.com/spf13/jwalterweatherman v1.1.0 // indirect github.com/spf13/pflag v1.0.5 // indirect - github.com/stretchr/objx v0.5.0 // indirect + github.com/stretchr/objx v0.5.2 // indirect github.com/subosito/gotenv v1.3.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.0 // indirect @@ -77,10 +78,12 @@ require ( github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 // indirect github.com/yudai/pp v2.0.1+incompatible // indirect + go.yaml.in/yaml/v2 v2.4.4 // indirect golang.org/x/crypto v0.52.0 // indirect golang.org/x/sys v0.45.0 // indirect google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 // indirect - google.golang.org/protobuf v1.36.10 // indirect + google.golang.org/protobuf v1.36.11 // indirect gopkg.in/ini.v1 v1.66.4 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect + lukechampine.com/blake3 v1.4.1 // indirect ) diff --git a/go.sum b/go.sum index 6184dea4d16..20ad3fe677b 100644 --- a/go.sum +++ b/go.sum @@ -63,6 +63,8 @@ github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMo github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/WURFL/golang-wurfl v1.30.3 h1:a/ZR+/mwMrA9cEVa88ig47zkVJNl3HM5OTCpPvoSYmE= github.com/WURFL/golang-wurfl v1.30.3/go.mod h1:cKXIyA0oIrbZ7YTOhBPX29ELt6XAM1/S7qyFIrTKkS0= +github.com/adcontextprotocol/adcp-go v0.0.0-20260703103742-c8f541ba6888 h1:fD/cUFNvjVbn9LCurcHXhnKsHVrzM9NzqmR8y6oJpe0= +github.com/adcontextprotocol/adcp-go v0.0.0-20260703103742-c8f541ba6888/go.mod h1:LgFcpyqcVaENPXZTrzP5M5jc6jltXo28zpCJ2qHG12c= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= @@ -122,8 +124,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46t github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/docker/go-units v0.4.0 h1:3uh0PgVws3nIA0Q+MwDC8yjEPf9zjRfZZWXZYDct3Tw= -github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= @@ -321,24 +323,30 @@ github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4d github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= +github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= +github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= -github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= -github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lib/pq v1.10.4 h1:SO9z7FRPzA03QhHKJrH5BXA6HU1rS4V2nIVrrNC1iYk= github.com/lib/pq v1.10.4/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lyft/protoc-gen-star v0.5.3/go.mod h1:V0xaHgaf5oCCqmcxYcWiDfTiKsZsRc87/1qhoTACD8w= github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= -github.com/magiconair/properties v1.8.6 h1:5ibWZ6iY0NctNGWo87LalDlEZ6R41TqbbDamhfG/Qzo= -github.com/magiconair/properties v1.8.6/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= @@ -352,7 +360,6 @@ github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOA github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= -github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso= @@ -381,6 +388,8 @@ github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lN github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= @@ -424,33 +433,37 @@ github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5Fsn github.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU= github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M= github.com/prometheus/client_golang v1.11.0/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0= -github.com/prometheus/client_golang v1.12.1 h1:ZiaPsmm9uiBeaSMRznKsCDNtPCS0T3JVDGF+06gjBzk= github.com/prometheus/client_golang v1.12.1/go.mod h1:3Z9XVyYiZYEO+YQWt3RD2R3jrbd179Rt297l4aS6nDY= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/client_model v0.2.0 h1:uq5h0d+GuxiXLJLNABMgp2qUWDPiLvgCzz2dUR+/W/M= github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4= github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo= github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc= -github.com/prometheus/common v0.32.1 h1:hWIdL3N2HoUx3B8j3YN9mWor0qhY/NlEKZEaXxuIRh4= github.com/prometheus/common v0.32.1/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls= +github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= +github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= -github.com/prometheus/procfs v0.7.3 h1:4jVXhlkAyzOScmCkXBTOLRLTz8EeU+eyjrwB/EPq0VU= github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= +github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= +github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= github.com/rcrowley/go-metrics v0.0.0-20190826022208-cac0b30c2563/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 h1:N/ElC8H3+5XpJzTSTfLsJV/mx9Q9g7kxmchpfZyxgzM= github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= -github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k= -github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/rs/cors v1.11.0 h1:0B9GE/r9Bc2UxRMMtymBkHTenPkHDv0CW4Y98GBY+po= github.com/rs/cors v1.11.0/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -483,8 +496,9 @@ github.com/spf13/viper v1.12.0/go.mod h1:b6COn30jlNxbm/V2IqWiNWkJ+vZNiMNksliPCiu github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= @@ -494,8 +508,8 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= github.com/subosito/gotenv v1.3.0 h1:mjC+YW8QpAdXibNi+vNWgzmgBH4+5l5dCXv8cNysBLI= github.com/subosito/gotenv v1.3.0/go.mod h1:YzJjq/33h7nrwdY+iHMhEOEEbW0ovIz0tB6t6PwAXzs= @@ -540,20 +554,24 @@ go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= -go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= -go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= -go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= -go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= -go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= -go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= -go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= -go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= -go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -1002,14 +1020,15 @@ google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlba google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= -google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d/go.mod h1:cuepJuh7vyXfUyUwEgHQXw849cJrilpS5NeIjOWESAw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/evanphx/json-patch.v5 v5.9.0 h1:hx1VU2SGj4F8r9b8GUwJLdc8DNO8sy79ZGui0G05GLo= gopkg.in/evanphx/json-patch.v5 v5.9.0/go.mod h1:/kvTRh1TVm5wuM6OkHxqXtE/1nUZZpihg29RtuIyfvk= @@ -1040,6 +1059,8 @@ honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWh honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg= +lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= diff --git a/modules/adcontextprotocol/tmp/README.md b/modules/adcontextprotocol/tmp/README.md new file mode 100644 index 00000000000..b09a78aff1b --- /dev/null +++ b/modules/adcontextprotocol/tmp/README.md @@ -0,0 +1,159 @@ +# AdContextProtocol TMP Module + +This module implements the [Trusted Match Protocol (TMP)](https://github.com/adcontextprotocol/adcp) +router role inside Prebid Server: + +- It converts each incoming OpenRTB bid request into a TMP `context_match_request` + and, when identity tokens are present, a TMP `identity_match_request`. +- It fans out to one or more TMP providers in parallel, signing every outbound + call with Ed25519 (`X-AdCP-Signature`, `X-AdCP-Key-Id`) per the TMP spec. +- It joins each provider's context offers with its identity eligibility set + locally and surfaces the surviving package IDs plus response-level signals on + the bid response. + +TMP wire types, signing and URL canonicalization come from +[`github.com/adcontextprotocol/adcp-go`](https://github.com/adcontextprotocol/adcp-go); +this module builds the OpenRTB→TMP mapping and the property registry client +on top. + +## Configuration + +```yaml +hooks: + enabled: true + modules: + adcontextprotocol: + tmp: + enabled: true + seller_agent_url: https://seller.example.com + signing: + key_id: kid-1 + # PEM (PKCS#8) Ed25519 private key. Substitute from environment in + # your deployment YAML. + private_key_pem: ${ADCP_TMP_SIGNING_KEY_PEM} + property_registry: + endpoint: https://agenticadvertising.org/api/properties/resolve + auth_bearer: ${ADCP_REGISTRY_TOKEN} # optional + cache_ttl_seconds: 3600 + negative_cache_ttl_seconds: 300 + cache_size: 4096 + timeout_ms: 500 + providers: + - name: example + identity_url: https://tmp.example.com/identity + context_url: https://tmp.example.com/context + timeout_ms: 200 + timeout_ms: 300 + cache_ttl_seconds: 60 + targeting_key: adcp + add_to_targeting: false + masking: + enabled: true + geo: + preserve_metro: true + preserve_zip: false + preserve_city: false + lat_long_precision: 2 + user: + preserve_eids: + - liveramp.com + - uidapi.com + - id5-sync.com + device: + preserve_mobile_ids: false + + host_execution_plan: + endpoints: + /openrtb2/auction: + stages: + entrypoint: + groups: + - timeout: 5 + hook_sequence: + - module_code: "adcontextprotocol.tmp" + hook_impl_code: "HandleEntrypointHook" + auction_processed: + groups: + - timeout: 500 + hook_sequence: + - module_code: "adcontextprotocol.tmp" + hook_impl_code: "HandleProcessedAuctionHook" + auction_response: + groups: + - timeout: 500 + hook_sequence: + - module_code: "adcontextprotocol.tmp" + hook_impl_code: "HandleAuctionResponseHook" +``` + +### Required fields + +| Field | Notes | +|-------|-------| +| `seller_agent_url` | Publicly reachable URL identifying this Prebid Server deployment as a seller agent. Must appear as one of `authorized_agents[].url` in the publisher's `adagents.json` (compared under AdCP URL canonicalization). | +| `signing.key_id` | Sent in `X-AdCP-Key-Id`. Verifiers use it to look up the matching Ed25519 public key. | +| `signing.private_key_pem` | PEM-encoded PKCS#8 Ed25519 private key. | +| `property_registry.endpoint` | Resolves `site.domain` / `app.bundle` → `property_rid` via a `GET ?domain=…` call. | +| `providers[].name` | Human-readable label; used as the prefix on emitted targeting keys. | +| `providers[].identity_url` or `providers[].context_url` | At least one is required per provider. | + +### Providers + +Each entry describes one downstream TMP provider. A provider may expose only +an identity endpoint, only a context endpoint, or both: + +- If only `context_url` is set, no identity match is performed for that + provider and all offers pass through unfiltered. +- If only `identity_url` is set, no offers are produced (eligibility with no + context is not useful on its own — the module drops that combination). +- If both are set, offers are intersected with the identity eligibility set. + +Providers are called in parallel; per-provider `timeout_ms` overrides the +module-level `timeout_ms`. + +### Property registry + +`site.domain` (or `app.bundle` when no site is present) is resolved to a +`property_rid` via the configured registry endpoint. Successful and negative +answers are cached in an in-memory LRU (`cache_size`, `cache_ttl_seconds`, +`negative_cache_ttl_seconds`). The first request from a cold domain may miss +its auction's timeout budget — that is expected; subsequent requests hit the +cache. + +## Response surface + +Merged signals are written to the auction response `ext` under the configured +`targeting_key` (default `adcp`): + +```json +{ + "ext": { + "adcp": { + "segments": [ + "example_package=pkg-fall-2026", + "example_segment=auto_intender" + ] + } + } +} +``` + +When `add_to_targeting: true`, each `key=value` pair is also mirrored into +`ext.prebid.targeting` so downstream ad servers (e.g. Google Ad Manager) can +consume them without a custom bridge. + +## Privacy + +- The TMP wire is decorrelated by design: context requests carry no identity + tokens, identity requests carry no page context. This module never mixes the + two payloads. +- Identity token count is capped at three, matching the TMP HPKE budget. +- Masking is applied to the context path (geo coarsening, EID allowlist) + before requests leave the process. Identity requests never carry the masked + fields to begin with. + +## References + +- TMP spec: [`adcontextprotocol/adcp`](https://github.com/adcontextprotocol/adcp) — `docs/trusted-match/specification.mdx` +- Go SDK: [`adcontextprotocol/adcp-go`](https://github.com/adcontextprotocol/adcp-go) — `tmproto`, `urlcanon` +- Property registry: [agenticadvertising.org](https://agenticadvertising.org) diff --git a/modules/adcontextprotocol/tmp/adapter.go b/modules/adcontextprotocol/tmp/adapter.go new file mode 100644 index 00000000000..511f0fed9fd --- /dev/null +++ b/modules/adcontextprotocol/tmp/adapter.go @@ -0,0 +1,214 @@ +package tmp + +import ( + "strings" + + "github.com/adcontextprotocol/adcp-go/tmproto" + "github.com/gofrs/uuid" + "github.com/prebid/openrtb/v20/openrtb2" + "github.com/prebid/prebid-server/v4/util/iterutil" + "github.com/prebid/prebid-server/v4/util/jsonutil" +) + +// tmpInputs is the intermediate shape produced by the OpenRTB→TMP adapter. +// The router turns this into per-provider ContextMatchRequest / IdentityMatchRequest. +type tmpInputs struct { + Domain string + Bundle string + PlacementID string + PropertyType tmproto.PropertyType + Geo map[string]any + Country string + ArtifactRefs []tmproto.ArtifactRef + Identities []tmproto.IdentityToken + Consent map[string]any +} + +// deriveInputs pulls the fields the TMP wire needs out of an OpenRTB bid +// request. Missing fields are omitted rather than defaulted — the wire schemas +// tolerate them. +func deriveInputs(cfg *Config, req *openrtb2.BidRequest) tmpInputs { + if req == nil { + return tmpInputs{} + } + out := tmpInputs{} + + if req.Site != nil { + out.Domain = req.Site.Domain + if req.Site.Page != "" { + out.ArtifactRefs = append(out.ArtifactRefs, tmproto.ArtifactRef{ + Type: tmproto.ArtifactRefTypeURL, + Value: req.Site.Page, + }) + } + } + if req.App != nil { + out.Bundle = req.App.Bundle + } + if def := cfg.DefaultPropertyType; def != "" { + out.PropertyType = tmproto.PropertyType(def) + } else if req.App != nil { + out.PropertyType = tmproto.PropertyTypeMobileApp + } else { + out.PropertyType = tmproto.PropertyTypeWebsite + } + + // Placement — take the first imp.tagid so the wire has something stable. + // Publishers with multiple placements per auction need one TMP request per + // placement; that is out of scope for this initial adapter. + for imp := range iterutil.SlicePointerValues(req.Imp) { + if imp.TagID != "" { + out.PlacementID = imp.TagID + break + } + } + + if req.Device != nil && req.Device.Geo != nil { + out.Geo = coarseGeo(req.Device.Geo) + out.Country = req.Device.Geo.Country + } else if req.User != nil && req.User.Geo != nil { + out.Geo = coarseGeo(req.User.Geo) + out.Country = req.User.Geo.Country + } + + if req.User != nil { + out.Identities = extractIdentities(req.User) + } + out.Consent = extractConsent(req) + + return out +} + +// coarseGeo drops fields the TMP context schema forbids (postcode, lat/long, +// accuracy) even after masking. Country / region / metro are the wire allowlist. +func coarseGeo(geo *openrtb2.Geo) map[string]any { + if geo == nil { + return nil + } + out := map[string]any{} + if geo.Country != "" { + out["country"] = geo.Country + } + if geo.Region != "" { + out["region"] = geo.Region + } + if geo.Metro != "" { + out["metro"] = geo.Metro + } + if len(out) == 0 { + return nil + } + return out +} + +// extractIdentities maps openrtb2 user.eids → tmproto.IdentityToken, honoring +// the TMP cap of three tokens. Priority order: rampid, uid2, id5, then whatever +// remains. Publishers that need a different priority should tell us — right now +// this is the most common set. +func extractIdentities(user *openrtb2.User) []tmproto.IdentityToken { + if user == nil { + return nil + } + priority := map[string]int{ + "liveramp.com": 0, + "uidapi.com": 1, + "id5-sync.com": 2, + "euid.eu": 3, + "adserver.org": 4, + } + type scored struct { + tok tmproto.IdentityToken + score int + } + var candidates []scored + + for eid := range iterutil.SlicePointerValues(user.EIDs) { + if len(eid.UIDs) == 0 || eid.UIDs[0].ID == "" { + continue + } + uidType := mapEIDToUIDType(eid.Source) + if uidType == "" { + continue + } + p, ok := priority[eid.Source] + if !ok { + p = 100 + } + candidates = append(candidates, scored{ + tok: tmproto.IdentityToken{ + UIDType: uidType, + UserToken: eid.UIDs[0].ID, + }, + score: p, + }) + } + + // Sort ascending by score; stable so equal-source entries keep encounter order. + for i := 1; i < len(candidates); i++ { + for j := i; j > 0 && candidates[j].score < candidates[j-1].score; j-- { + candidates[j], candidates[j-1] = candidates[j-1], candidates[j] + } + } + + const maxTokens = 3 + if len(candidates) > maxTokens { + candidates = candidates[:maxTokens] + } + out := make([]tmproto.IdentityToken, 0, len(candidates)) + for _, c := range candidates { + out = append(out, c.tok) + } + return out +} + +// mapEIDToUIDType translates the OpenRTB EID source to the TMP uid_type enum. +// Unrecognized sources return "" so the caller drops them; the wire schema +// caps at three tokens and unknown types would just consume budget. +func mapEIDToUIDType(source string) tmproto.UIDType { + switch strings.ToLower(source) { + case "liveramp.com": + return tmproto.UIDTypeRampID + case "uidapi.com": + return tmproto.UIDTypeUID2 + case "id5-sync.com": + return tmproto.UIDTypeID5 + case "euid.eu": + return tmproto.UIDTypeEUID + case "adserver.org": + return tmproto.UIDTypePairID + } + return "" +} + +// extractConsent surfaces the standard consent fields the identity wire +// tolerates. Buyers in regulated jurisdictions require this. +func extractConsent(req *openrtb2.BidRequest) map[string]any { + out := map[string]any{} + if req.Regs != nil && req.Regs.GPP != "" { + out["gpp"] = req.Regs.GPP + } + if req.User != nil && len(req.User.Ext) > 0 { + var ext struct { + Consent string `json:"consent"` + } + if err := jsonutil.Unmarshal(req.User.Ext, &ext); err == nil && ext.Consent != "" { + out["gdpr_tcf"] = ext.Consent + } + } + if len(out) == 0 { + return nil + } + return out +} + +// newRequestID returns a UUID v4 formatted for the TMP wire. +func newRequestID() string { + u, err := uuid.NewV4() + if err != nil { + // Extremely unlikely (would need a broken RNG). Fall back to a + // deterministic identifier — signature verification is unaffected but + // dedup at the provider may be weaker on repeats. + return "adcp-tmp-request" + } + return u.String() +} diff --git a/modules/adcontextprotocol/tmp/adapter_test.go b/modules/adcontextprotocol/tmp/adapter_test.go new file mode 100644 index 00000000000..68499948b39 --- /dev/null +++ b/modules/adcontextprotocol/tmp/adapter_test.go @@ -0,0 +1,111 @@ +package tmp + +import ( + "testing" + + "github.com/adcontextprotocol/adcp-go/tmproto" + "github.com/prebid/openrtb/v20/openrtb2" +) + +func TestDeriveInputs_Site(t *testing.T) { + cfg := &Config{} + req := &openrtb2.BidRequest{ + Site: &openrtb2.Site{Domain: "example.com", Page: "https://example.com/article"}, + Imp: []openrtb2.Imp{{TagID: "slot-1"}}, + Device: &openrtb2.Device{ + Geo: &openrtb2.Geo{Country: "US", Region: "CA", Metro: "807"}, + }, + } + in := deriveInputs(cfg, req) + + if in.Domain != "example.com" { + t.Errorf("Domain = %q, want %q", in.Domain, "example.com") + } + if in.PlacementID != "slot-1" { + t.Errorf("PlacementID = %q, want %q", in.PlacementID, "slot-1") + } + if in.PropertyType != tmproto.PropertyTypeWebsite { + t.Errorf("PropertyType = %q, want website", in.PropertyType) + } + if len(in.ArtifactRefs) != 1 || in.ArtifactRefs[0].Type != tmproto.ArtifactRefTypeURL { + t.Errorf("ArtifactRefs = %+v, want single url ref", in.ArtifactRefs) + } + if in.Country != "US" { + t.Errorf("Country = %q, want %q", in.Country, "US") + } + if in.Geo["metro"] != "807" { + t.Errorf("Geo[metro] = %v, want 807", in.Geo["metro"]) + } +} + +func TestDeriveInputs_App(t *testing.T) { + cfg := &Config{} + req := &openrtb2.BidRequest{ + App: &openrtb2.App{Bundle: "com.example.app"}, + } + in := deriveInputs(cfg, req) + if in.Bundle != "com.example.app" { + t.Errorf("Bundle = %q, want com.example.app", in.Bundle) + } + if in.PropertyType != tmproto.PropertyTypeMobileApp { + t.Errorf("PropertyType = %q, want mobile_app", in.PropertyType) + } +} + +func TestDeriveInputs_IdentityCap(t *testing.T) { + cfg := &Config{} + req := &openrtb2.BidRequest{ + User: &openrtb2.User{ + EIDs: []openrtb2.EID{ + {Source: "id5-sync.com", UIDs: []openrtb2.UID{{ID: "id5-x"}}}, + {Source: "liveramp.com", UIDs: []openrtb2.UID{{ID: "ramp-x"}}}, + {Source: "uidapi.com", UIDs: []openrtb2.UID{{ID: "uid2-x"}}}, + {Source: "adserver.org", UIDs: []openrtb2.UID{{ID: "pair-x"}}}, + {Source: "unknown", UIDs: []openrtb2.UID{{ID: "u"}}}, + }, + }, + } + in := deriveInputs(cfg, req) + if len(in.Identities) != 3 { + t.Fatalf("Identities length = %d, want 3", len(in.Identities)) + } + // Priority: liveramp, uidapi, id5. + if in.Identities[0].UIDType != tmproto.UIDTypeRampID { + t.Errorf("first identity uid_type = %q, want rampid", in.Identities[0].UIDType) + } + if in.Identities[1].UIDType != tmproto.UIDTypeUID2 { + t.Errorf("second identity uid_type = %q, want uid2", in.Identities[1].UIDType) + } + if in.Identities[2].UIDType != tmproto.UIDTypeID5 { + t.Errorf("third identity uid_type = %q, want id5", in.Identities[2].UIDType) + } +} + +func TestDeriveInputs_ConsentGPP(t *testing.T) { + cfg := &Config{} + req := &openrtb2.BidRequest{ + Regs: &openrtb2.Regs{GPP: "DBABMA~CPXxRfAPXxRfAAfKABENB-CgAAAAAAAAAAYgAAAAAAAA"}, + } + in := deriveInputs(cfg, req) + if in.Consent["gpp"] == nil { + t.Errorf("Consent.gpp missing, got %+v", in.Consent) + } +} + +func TestMapEIDToUIDType(t *testing.T) { + cases := map[string]tmproto.UIDType{ + "liveramp.com": tmproto.UIDTypeRampID, + "uidapi.com": tmproto.UIDTypeUID2, + "id5-sync.com": tmproto.UIDTypeID5, + "euid.eu": tmproto.UIDTypeEUID, + "adserver.org": tmproto.UIDTypePairID, + "unknown": "", + "": "", + } + for src, want := range cases { + got := mapEIDToUIDType(src) + if got != want { + t.Errorf("mapEIDToUIDType(%q) = %q, want %q", src, got, want) + } + } +} diff --git a/modules/adcontextprotocol/tmp/config.go b/modules/adcontextprotocol/tmp/config.go new file mode 100644 index 00000000000..2706759f0bb --- /dev/null +++ b/modules/adcontextprotocol/tmp/config.go @@ -0,0 +1,189 @@ +package tmp + +import ( + "crypto/ed25519" + "errors" + "fmt" + + "github.com/adcontextprotocol/adcp-go/tmproto" +) + +// Config is the JSON configuration for the module. See README.md. +type Config struct { + // SellerAgentURL identifies this Prebid Server deployment as a seller agent. + // MUST match one of the property's adagents.json authorized_agents[].url + // entries (compared with AdCP URL canonicalization). Same value for every + // user on a given placement — carries no user identity. + SellerAgentURL string `json:"seller_agent_url"` + + // PropertyType default when the registry does not return one. Optional. + DefaultPropertyType string `json:"default_property_type"` + + // TimeoutMs is the overall budget for the TMP fan-out. Individual providers + // can override with their own Timeout field. Default 300 ms. + TimeoutMs int `json:"timeout_ms"` + + // CacheTTLSeconds is the TTL for the local response cache used when the + // provider does not return an explicit cache_ttl. Default 60 s. + CacheTTLSeconds int `json:"cache_ttl_seconds"` + + // Signing holds the Ed25519 key used to authenticate outbound requests to + // TMP providers. Required. + Signing SigningConfig `json:"signing"` + + // PropertyRegistry configures the adcp property catalog client used to + // resolve domain → property_rid. + PropertyRegistry PropertyRegistryConfig `json:"property_registry"` + + // Providers is the list of downstream TMP providers to fan out to. At least + // one is required. Each provider must have at least one of IdentityURL or + // ContextURL configured. + Providers []ProviderConfig `json:"providers"` + + // Masking optionally coarsens the ContextMatch payload before it leaves the + // server. Identity payloads never carry the fields Masking operates on. + Masking MaskingConfig `json:"masking"` + + // TargetingKey is the ext key on the bid response under which we surface + // merged TMP signals. Defaults to "adcp". + TargetingKey string `json:"targeting_key"` + + // AddToTargeting mirrors the response signals into prebid.targeting so + // downstream ad servers (e.g. GAM) can consume them. + AddToTargeting bool `json:"add_to_targeting"` +} + +// SigningConfig carries the private-key material used to sign outbound TMP +// requests. Ed25519 per the TMP spec. +type SigningConfig struct { + // KeyID is echoed in the X-AdCP-Key-Id header so verifiers can look up the + // matching public key in the property registry. + KeyID string `json:"key_id"` + // PrivateKeyPEM holds the PEM-encoded PKCS#8 Ed25519 private key. Deployments + // substitute this from the environment via yaml env expansion (e.g. + // ${ADCP_TMP_SIGNING_KEY_PEM}) — the module itself receives it as a string. + PrivateKeyPEM string `json:"private_key_pem"` +} + +// PropertyRegistryConfig configures the domain → property_rid resolver. +type PropertyRegistryConfig struct { + // Endpoint is the resolve endpoint of the property registry, e.g. + // https://agenticadvertising.org/api/properties/resolve. Domain is + // appended as ?domain=… on GET. + Endpoint string `json:"endpoint"` + // AuthBearer is the optional bearer token sent as Authorization: Bearer … + // on registry calls. May be substituted from env in deployment YAML. + AuthBearer string `json:"auth_bearer"` + // CacheTTLSeconds is how long a successful lookup is memoized. Default 3600. + CacheTTLSeconds int `json:"cache_ttl_seconds"` + // NegativeCacheTTLSeconds is how long a "not found" answer is memoized. Default 300. + NegativeCacheTTLSeconds int `json:"negative_cache_ttl_seconds"` + // CacheSize is the max number of entries kept in memory. Default 4096. + CacheSize int `json:"cache_size"` + // TimeoutMs bounds a single registry HTTP call. Default 500. + TimeoutMs int `json:"timeout_ms"` +} + +// ProviderConfig describes a single downstream TMP provider (identity agent, +// context agent, or both). +type ProviderConfig struct { + Name string `json:"name"` + // IdentityURL, if set, receives IdentityMatch requests. + IdentityURL string `json:"identity_url"` + // ContextURL, if set, receives ContextMatch requests. + ContextURL string `json:"context_url"` + // TimeoutMs overrides the module-level timeout for this provider. Optional. + TimeoutMs int `json:"timeout_ms"` +} + +// MaskingConfig mirrors the categories the previous RTD module exposed, so +// operators can migrate configuration in-place. +type MaskingConfig struct { + Enabled bool `json:"enabled"` + Geo GeoMaskingConfig `json:"geo"` + User UserMaskingConfig `json:"user"` + Device DeviceMaskingConfig `json:"device"` +} + +type GeoMaskingConfig struct { + PreserveMetro bool `json:"preserve_metro"` + PreserveZip bool `json:"preserve_zip"` + PreserveCity bool `json:"preserve_city"` + LatLongPrecision int `json:"lat_long_precision"` +} + +type UserMaskingConfig struct { + PreserveEids []string `json:"preserve_eids"` +} + +type DeviceMaskingConfig struct { + PreserveMobileIds bool `json:"preserve_mobile_ids"` +} + +// validated returns a Config with defaults filled in, along with the parsed +// Ed25519 private key. Invalid configuration is rejected here rather than at +// call sites. +func (c *Config) validated() (ed25519.PrivateKey, error) { + if c.SellerAgentURL == "" { + return nil, errors.New("seller_agent_url is required") + } + if c.Signing.KeyID == "" { + return nil, errors.New("signing.key_id is required") + } + if c.Signing.PrivateKeyPEM == "" { + return nil, errors.New("signing.private_key_pem is required") + } + priv, err := tmproto.LoadEd25519PrivateKeyPEM([]byte(c.Signing.PrivateKeyPEM)) + if err != nil { + return nil, fmt.Errorf("signing.private_key_pem: %w", err) + } + if len(c.Providers) == 0 { + return nil, errors.New("at least one provider is required") + } + for i := range c.Providers { + p := &c.Providers[i] + if p.Name == "" { + return nil, fmt.Errorf("providers[%d].name is required", i) + } + if p.IdentityURL == "" && p.ContextURL == "" { + return nil, fmt.Errorf("providers[%d] (%s): at least one of identity_url or context_url is required", i, p.Name) + } + } + if c.PropertyRegistry.Endpoint == "" { + return nil, errors.New("property_registry.endpoint is required") + } + + if c.TimeoutMs <= 0 { + c.TimeoutMs = 300 + } + if c.CacheTTLSeconds <= 0 { + c.CacheTTLSeconds = 60 + } + if c.PropertyRegistry.CacheTTLSeconds <= 0 { + c.PropertyRegistry.CacheTTLSeconds = 3600 + } + if c.PropertyRegistry.NegativeCacheTTLSeconds <= 0 { + c.PropertyRegistry.NegativeCacheTTLSeconds = 300 + } + if c.PropertyRegistry.CacheSize <= 0 { + c.PropertyRegistry.CacheSize = 4096 + } + if c.PropertyRegistry.TimeoutMs <= 0 { + c.PropertyRegistry.TimeoutMs = 500 + } + if c.TargetingKey == "" { + c.TargetingKey = "adcp" + } + if c.Masking.Enabled { + if c.Masking.Geo.LatLongPrecision > 4 { + return nil, errors.New("masking.geo.lat_long_precision cannot exceed 4") + } + if c.Masking.Geo.LatLongPrecision < 0 { + return nil, errors.New("masking.geo.lat_long_precision cannot be negative") + } + if len(c.Masking.User.PreserveEids) == 0 { + c.Masking.User.PreserveEids = []string{"liveramp.com", "uidapi.com", "id5-sync.com"} + } + } + return priv, nil +} diff --git a/modules/adcontextprotocol/tmp/config_test.go b/modules/adcontextprotocol/tmp/config_test.go new file mode 100644 index 00000000000..c5dbf1d0b5d --- /dev/null +++ b/modules/adcontextprotocol/tmp/config_test.go @@ -0,0 +1,115 @@ +package tmp + +import ( + "crypto/ed25519" + "crypto/rand" + "crypto/x509" + "encoding/pem" + "strings" + "testing" +) + +// genTestKey returns a fresh Ed25519 keypair in PKCS#8 PEM form, ready to drop +// into SigningConfig.PrivateKeyPEM. Kept here so every test can produce a valid +// key without pulling in adcp-go's helpers. +func genTestKey(t *testing.T) string { + t.Helper() + _, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("generate key: %v", err) + } + der, err := x509.MarshalPKCS8PrivateKey(priv) + if err != nil { + t.Fatalf("marshal pkcs8: %v", err) + } + block := &pem.Block{Type: "PRIVATE KEY", Bytes: der} + return string(pem.EncodeToMemory(block)) +} + +func validConfig(t *testing.T) Config { + return Config{ + SellerAgentURL: "https://seller.example.com", + Signing: SigningConfig{ + KeyID: "kid-1", + PrivateKeyPEM: genTestKey(t), + }, + PropertyRegistry: PropertyRegistryConfig{ + Endpoint: "https://agenticadvertising.org/api/properties/resolve", + }, + Providers: []ProviderConfig{ + { + Name: "example", + IdentityURL: "https://tmp.example.com/identity", + ContextURL: "https://tmp.example.com/context", + }, + }, + } +} + +func TestValidated_Defaults(t *testing.T) { + cfg := validConfig(t) + if _, err := cfg.validated(); err != nil { + t.Fatalf("expected valid config, got %v", err) + } + if cfg.TimeoutMs != 300 { + t.Errorf("TimeoutMs default = %d, want 300", cfg.TimeoutMs) + } + if cfg.CacheTTLSeconds != 60 { + t.Errorf("CacheTTLSeconds default = %d, want 60", cfg.CacheTTLSeconds) + } + if cfg.PropertyRegistry.CacheTTLSeconds != 3600 { + t.Errorf("PropertyRegistry.CacheTTLSeconds default = %d, want 3600", cfg.PropertyRegistry.CacheTTLSeconds) + } + if cfg.TargetingKey != "adcp" { + t.Errorf("TargetingKey default = %q, want %q", cfg.TargetingKey, "adcp") + } +} + +func TestValidated_ProviderNeedsAtLeastOneURL(t *testing.T) { + cfg := validConfig(t) + cfg.Providers[0].IdentityURL = "" + cfg.Providers[0].ContextURL = "" + _, err := cfg.validated() + if err == nil { + t.Fatal("expected error when both provider URLs are empty") + } + if !strings.Contains(err.Error(), "at least one of identity_url or context_url") { + t.Errorf("unexpected error message: %v", err) + } +} + +func TestValidated_MissingSellerAgentURL(t *testing.T) { + cfg := validConfig(t) + cfg.SellerAgentURL = "" + if _, err := cfg.validated(); err == nil { + t.Fatal("expected error for missing seller_agent_url") + } +} + +func TestValidated_MissingSigningKey(t *testing.T) { + cfg := validConfig(t) + cfg.Signing.PrivateKeyPEM = "" + if _, err := cfg.validated(); err == nil { + t.Fatal("expected error for missing signing.private_key_pem") + } +} + +func TestValidated_LatLongPrecisionCapped(t *testing.T) { + cfg := validConfig(t) + cfg.Masking.Enabled = true + cfg.Masking.Geo.LatLongPrecision = 5 + if _, err := cfg.validated(); err == nil { + t.Fatal("expected error when lat_long_precision > 4") + } +} + +func TestValidated_MaskingDefaultEIDList(t *testing.T) { + cfg := validConfig(t) + cfg.Masking.Enabled = true + if _, err := cfg.validated(); err != nil { + t.Fatalf("expected valid, got %v", err) + } + if len(cfg.Masking.User.PreserveEids) == 0 { + t.Fatal("expected default EID list to be populated when masking is enabled") + } +} diff --git a/modules/adcontextprotocol/tmp/hooks.go b/modules/adcontextprotocol/tmp/hooks.go new file mode 100644 index 00000000000..7075b8a77e9 --- /dev/null +++ b/modules/adcontextprotocol/tmp/hooks.go @@ -0,0 +1,138 @@ +package tmp + +import ( + "context" + + "github.com/prebid/prebid-server/v4/hooks/hookanalytics" + "github.com/prebid/prebid-server/v4/hooks/hookstage" + "github.com/prebid/prebid-server/v4/logger" + "github.com/tidwall/sjson" +) + +// HandleEntrypointHook allocates the per-auction async request holder. +func (m *Module) HandleEntrypointHook( + _ context.Context, + _ hookstage.ModuleInvocationContext, + _ hookstage.EntrypointPayload, +) (hookstage.HookResult[hookstage.EntrypointPayload], error) { + moduleContext := hookstage.NewModuleContext() + moduleContext.Set(asyncKey, &asyncRequest{done: make(chan struct{})}) + return hookstage.HookResult[hookstage.EntrypointPayload]{ModuleContext: moduleContext}, nil +} + +// HandleProcessedAuctionHook kicks off the TMP fan-out in the background. The +// auction continues immediately; results are collected in the response hook. +func (m *Module) HandleProcessedAuctionHook( + ctx context.Context, + miCtx hookstage.ModuleInvocationContext, + payload hookstage.ProcessedAuctionRequestPayload, +) (hookstage.HookResult[hookstage.ProcessedAuctionRequestPayload], error) { + var res hookstage.HookResult[hookstage.ProcessedAuctionRequestPayload] + + async, ok := m.loadAsync(miCtx) + if !ok { + return res, nil + } + if payload.Request == nil || payload.Request.BidRequest == nil { + close(async.done) + return res, nil + } + bidRequest := payload.Request.BidRequest + + go func() { + defer func() { + if r := recover(); r != nil { + logger.Errorf("adcontextprotocol.tmp: panic in fan-out: %v", r) + } + close(async.done) + }() + async.result = m.fanOut(ctx, bidRequest) + }() + return res, nil +} + +// HandleAuctionResponseHook joins fan-out results with the bid response. +func (m *Module) HandleAuctionResponseHook( + ctx context.Context, + miCtx hookstage.ModuleInvocationContext, + _ hookstage.AuctionResponsePayload, +) (hookstage.HookResult[hookstage.AuctionResponsePayload], error) { + var res hookstage.HookResult[hookstage.AuctionResponsePayload] + + async, ok := m.loadAsync(miCtx) + if !ok { + return res, nil + } + select { + case <-async.done: + case <-ctx.Done(): + return res, nil + } + if async.result == nil || len(async.result.Segments) == 0 { + return res, nil + } + segments := async.result.Segments + targetingKey := m.cfg.TargetingKey + addToTargeting := m.cfg.AddToTargeting + + res.ChangeSet.AddMutation( + func(payload hookstage.AuctionResponsePayload) (hookstage.AuctionResponsePayload, error) { + ext := payload.BidResponse.Ext + newExt, err := sjson.SetBytes(ext, targetingKey+".segments", segments) + if err != nil { + logger.Errorf("adcontextprotocol.tmp: failed to set %s.segments on response ext: %v", targetingKey, err) + } else { + ext = newExt + } + if addToTargeting { + for _, s := range segments { + kv := splitKV(s) + if kv == nil { + continue + } + newExt, err := sjson.SetBytes(ext, "prebid.targeting."+kv[0], kv[1]) + if err != nil { + logger.Errorf("adcontextprotocol.tmp: targeting set: %v", err) + continue + } + ext = newExt + } + } + payload.BidResponse.Ext = ext + return payload, nil + }, + hookstage.MutationUpdate, + "ext", + ) + + res.AnalyticsTags = hookanalytics.Analytics{ + Activities: []hookanalytics.Activity{{ + Name: "adcontextprotocol.tmp.fanout", + Status: hookanalytics.ActivityStatusSuccess, + Results: []hookanalytics.Result{{ + Status: hookanalytics.ResultStatusAllow, + Values: map[string]any{"segments": len(segments)}, + }}, + }}, + } + return res, nil +} + +func (m *Module) loadAsync(miCtx hookstage.ModuleInvocationContext) (*asyncRequest, bool) { + v, ok := miCtx.ModuleContext.Get(asyncKey) + if !ok { + return nil, false + } + a, ok := v.(*asyncRequest) + return a, ok +} + +// splitKV splits "key=value" once; returns nil for malformed input. +func splitKV(s string) []string { + for i := 0; i < len(s); i++ { + if s[i] == '=' { + return []string{s[:i], s[i+1:]} + } + } + return nil +} diff --git a/modules/adcontextprotocol/tmp/masking.go b/modules/adcontextprotocol/tmp/masking.go new file mode 100644 index 00000000000..03c3e86a936 --- /dev/null +++ b/modules/adcontextprotocol/tmp/masking.go @@ -0,0 +1,61 @@ +package tmp + +import ( + "github.com/adcontextprotocol/adcp-go/tmproto" +) + +// maskGeoMap coarsens a TMP context geo map according to the module's masking +// configuration. The TMP context schema already forbids postcode and lat/long, +// so this operates on the enum-safe fields (metro, region, country, city). +// Returns nil if masking removed everything. +func (m *Module) maskGeoMap(geo map[string]any) map[string]any { + if geo == nil { + return nil + } + out := make(map[string]any, len(geo)) + for k, v := range geo { + switch k { + case "country", "region": + out[k] = v + case "metro": + if m.cfg.Masking.Geo.PreserveMetro { + out[k] = v + } + case "city": + if m.cfg.Masking.Geo.PreserveCity { + out[k] = v + } + case "zip", "zipcode": + if m.cfg.Masking.Geo.PreserveZip { + out[k] = v + } + } + } + if len(out) == 0 { + return nil + } + return out +} + +// filterIdentities drops any identity token whose source is not on the +// preserve_eids allowlist. Called for defense-in-depth: mapEIDToUIDType +// already restricts to sources the TMP wire recognizes, but operators may want +// a tighter allowlist per jurisdiction. +func (m *Module) filterIdentities(tokens []tmproto.IdentityToken) []tmproto.IdentityToken { + if len(m.cfg.Masking.User.PreserveEids) == 0 { + return tokens + } + allowed := make(map[tmproto.UIDType]bool, len(m.cfg.Masking.User.PreserveEids)) + for _, src := range m.cfg.Masking.User.PreserveEids { + if t := mapEIDToUIDType(src); t != "" { + allowed[t] = true + } + } + out := tokens[:0] + for _, t := range tokens { + if allowed[t.UIDType] { + out = append(out, t) + } + } + return out +} diff --git a/modules/adcontextprotocol/tmp/module.go b/modules/adcontextprotocol/tmp/module.go new file mode 100644 index 00000000000..331dd0d654a --- /dev/null +++ b/modules/adcontextprotocol/tmp/module.go @@ -0,0 +1,78 @@ +// Package tmp implements a Prebid Server module that acts as an OpenRTB→TMP +// adapter and TMP router. Each auction is fanned out to one or more configured +// TMP providers (identity agent, context agent, or both); responses are joined +// locally and surfaced on the bid response. +// +// The wire types, signing primitives and URL canonicalization come from +// github.com/adcontextprotocol/adcp-go. Property resolution and OpenRTB +// mapping are implemented here — adcp-go does not provide them. +package tmp + +import ( + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/adcontextprotocol/adcp-go/tmproto" + "github.com/prebid/prebid-server/v4/hooks/hookstage" + "github.com/prebid/prebid-server/v4/modules/moduledeps" + "github.com/prebid/prebid-server/v4/util/jsonutil" +) + +// Builder is the entry point Prebid Server uses to instantiate the module. +func Builder(raw json.RawMessage, deps moduledeps.ModuleDeps) (any, error) { + var cfg Config + if err := jsonutil.Unmarshal(raw, &cfg); err != nil { + return nil, fmt.Errorf("adcontextprotocol.tmp: unmarshal config: %w", err) + } + + privKey, err := cfg.validated() + if err != nil { + return nil, fmt.Errorf("adcontextprotocol.tmp: invalid config: %w", err) + } + + signer, err := tmproto.NewSigner(cfg.Signing.KeyID, privKey) + if err != nil { + return nil, fmt.Errorf("adcontextprotocol.tmp: signer: %w", err) + } + + httpClient := &http.Client{ + Timeout: time.Duration(cfg.TimeoutMs) * time.Millisecond, + Transport: deps.HTTPClient.Transport, + } + + return &Module{ + cfg: cfg, + signer: signer, + http: httpClient, + registry: newPropertyResolver(cfg.PropertyRegistry, deps.HTTPClient.Transport), + }, nil +} + +// Module is the running module instance. +type Module struct { + cfg Config + signer *tmproto.Signer + http *http.Client + registry *propertyResolver +} + +// asyncKey names the entry we stash the in-flight request under on the module +// invocation context. +const asyncKey = "adcontextprotocol.tmp.asyncRequest" + +// Hook interface assertions — the compiler catches signature drift here. +var ( + _ hookstage.Entrypoint = (*Module)(nil) + _ hookstage.ProcessedAuctionRequest = (*Module)(nil) + _ hookstage.AuctionResponse = (*Module)(nil) +) + +// asyncRequest carries a single auction's in-flight TMP fan-out from the +// entrypoint hook through to the response hook. +type asyncRequest struct { + done chan struct{} + result *routerResult + err error +} diff --git a/modules/adcontextprotocol/tmp/property_registry.go b/modules/adcontextprotocol/tmp/property_registry.go new file mode 100644 index 00000000000..bd78f7785a6 --- /dev/null +++ b/modules/adcontextprotocol/tmp/property_registry.go @@ -0,0 +1,227 @@ +package tmp + +import ( + "container/list" + "context" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/adcontextprotocol/adcp-go/tmproto" + "github.com/prebid/prebid-server/v4/util/jsonutil" +) + +// PropertyRecord is the subset of a registry property record the module needs. +// The registry may return more fields — we ignore what we don't use. +type PropertyRecord struct { + PropertyRID string `json:"property_rid"` + PropertyID string `json:"property_id"` + PropertyType tmproto.PropertyType `json:"property_type"` + Domain string `json:"domain"` +} + +// registryResponse mirrors the resolve endpoint's JSON envelope. The spec at +// agenticadvertising.org returns either a single property or a "not found" +// signal — modeled here so callers can distinguish "no such domain" from an +// upstream error. +type registryResponse struct { + Property *PropertyRecord `json:"property"` + Found *bool `json:"found,omitempty"` +} + +// propertyResolver resolves site.domain / app.bundle → PropertyRecord with an +// in-memory expirable LRU cache. The first call from a cold domain may miss +// the auction's timeout budget; subsequent calls hit the cache. +type propertyResolver struct { + cfg PropertyRegistryConfig + http *http.Client + mu sync.Mutex + order *list.List + items map[string]*list.Element + single singleflight +} + +type cacheEntry struct { + key string + record *PropertyRecord // nil = negative cache (domain not registered) + expires time.Time +} + +func newPropertyResolver(cfg PropertyRegistryConfig, transport http.RoundTripper) *propertyResolver { + return &propertyResolver{ + cfg: cfg, + http: &http.Client{ + Timeout: time.Duration(cfg.TimeoutMs) * time.Millisecond, + Transport: transport, + }, + order: list.New(), + items: make(map[string]*list.Element), + } +} + +// Resolve looks up a property by canonical domain (site) or bundle (app). +// Returns (record, true, nil) on hit, (nil, false, nil) on cached negative, +// (nil, false, err) on registry error. +func (p *propertyResolver) Resolve(ctx context.Context, domain string) (*PropertyRecord, bool, error) { + key := strings.ToLower(strings.TrimSpace(domain)) + if key == "" { + return nil, false, errors.New("empty domain") + } + + if rec, ok, fresh := p.cacheGet(key); fresh { + return rec, ok, nil + } + + // Single-flight: collapse concurrent misses for the same domain onto one HTTP call. + rec, err := p.single.do(key, func() (*PropertyRecord, error) { + return p.fetch(ctx, key) + }) + if err != nil { + return nil, false, err + } + if rec == nil { + p.cachePut(key, nil, time.Duration(p.cfg.NegativeCacheTTLSeconds)*time.Second) + return nil, false, nil + } + p.cachePut(key, rec, time.Duration(p.cfg.CacheTTLSeconds)*time.Second) + return rec, true, nil +} + +func (p *propertyResolver) fetch(ctx context.Context, domain string) (*PropertyRecord, error) { + q := url.Values{} + q.Set("domain", domain) + fullURL := p.cfg.Endpoint + if strings.Contains(fullURL, "?") { + fullURL += "&" + q.Encode() + } else { + fullURL += "?" + q.Encode() + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil) + if err != nil { + return nil, err + } + if p.cfg.AuthBearer != "" { + req.Header.Set("Authorization", "Bearer "+p.cfg.AuthBearer) + } + req.Header.Set("Accept", "application/json") + + resp, err := p.http.Do(req) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + + switch resp.StatusCode { + case http.StatusOK: + raw, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("registry read: %w", err) + } + var body registryResponse + if err := jsonutil.Unmarshal(raw, &body); err != nil { + return nil, fmt.Errorf("registry decode: %w", err) + } + // Some registry implementations return the record directly, others wrap + // it in {"property": {...}}. Handle both. + if body.Property != nil { + if body.Property.PropertyRID != "" { + return body.Property, nil + } + } + if body.Found != nil && !*body.Found { + return nil, nil + } + return nil, nil + case http.StatusNotFound: + return nil, nil + case http.StatusUnauthorized, http.StatusForbidden: + return nil, fmt.Errorf("registry auth failed: status %d", resp.StatusCode) + default: + return nil, fmt.Errorf("registry status %d", resp.StatusCode) + } +} + +func (p *propertyResolver) cacheGet(key string) (*PropertyRecord, bool, bool) { + p.mu.Lock() + defer p.mu.Unlock() + el, ok := p.items[key] + if !ok { + return nil, false, false + } + entry := el.Value.(*cacheEntry) + if time.Now().After(entry.expires) { + p.order.Remove(el) + delete(p.items, key) + return nil, false, false + } + p.order.MoveToFront(el) + return entry.record, entry.record != nil, true +} + +func (p *propertyResolver) cachePut(key string, rec *PropertyRecord, ttl time.Duration) { + if ttl <= 0 { + return + } + p.mu.Lock() + defer p.mu.Unlock() + if el, ok := p.items[key]; ok { + entry := el.Value.(*cacheEntry) + entry.record = rec + entry.expires = time.Now().Add(ttl) + p.order.MoveToFront(el) + return + } + entry := &cacheEntry{key: key, record: rec, expires: time.Now().Add(ttl)} + el := p.order.PushFront(entry) + p.items[key] = el + for p.order.Len() > p.cfg.CacheSize { + back := p.order.Back() + if back == nil { + break + } + p.order.Remove(back) + delete(p.items, back.Value.(*cacheEntry).key) + } +} + +// singleflight collapses concurrent fetches for the same key onto one call. +// A tiny local implementation avoids pulling golang.org/x/sync just for this. +type singleflight struct { + mu sync.Mutex + calls map[string]*sfCall +} + +type sfCall struct { + wg sync.WaitGroup + rec *PropertyRecord + err error +} + +func (s *singleflight) do(key string, fn func() (*PropertyRecord, error)) (*PropertyRecord, error) { + s.mu.Lock() + if s.calls == nil { + s.calls = make(map[string]*sfCall) + } + if c, ok := s.calls[key]; ok { + s.mu.Unlock() + c.wg.Wait() + return c.rec, c.err + } + c := &sfCall{} + c.wg.Add(1) + s.calls[key] = c + s.mu.Unlock() + + c.rec, c.err = fn() + c.wg.Done() + + s.mu.Lock() + delete(s.calls, key) + s.mu.Unlock() + return c.rec, c.err +} diff --git a/modules/adcontextprotocol/tmp/property_registry_test.go b/modules/adcontextprotocol/tmp/property_registry_test.go new file mode 100644 index 00000000000..9484b681b21 --- /dev/null +++ b/modules/adcontextprotocol/tmp/property_registry_test.go @@ -0,0 +1,151 @@ +package tmp + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" +) + +func TestPropertyResolver_Cache(t *testing.T) { + var calls int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&calls, 1) + domain := r.URL.Query().Get("domain") + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "property": map[string]any{ + "property_rid": "01916f3a-1234-7000-8000-000000000001", + "property_id": "example", + "property_type": "website", + "domain": domain, + }, + }) + })) + defer srv.Close() + + r := newPropertyResolver(PropertyRegistryConfig{ + Endpoint: srv.URL, + CacheTTLSeconds: 60, + CacheSize: 16, + TimeoutMs: 500, + }, nil) + + ctx := context.Background() + rec1, ok, err := r.Resolve(ctx, "example.com") + if err != nil || !ok || rec1.PropertyRID == "" { + t.Fatalf("first resolve: rec=%+v ok=%v err=%v", rec1, ok, err) + } + rec2, ok, err := r.Resolve(ctx, "example.com") + if err != nil || !ok || rec2.PropertyRID != rec1.PropertyRID { + t.Fatalf("second resolve did not hit cache: rec=%+v ok=%v err=%v", rec2, ok, err) + } + if got := atomic.LoadInt32(&calls); got != 1 { + t.Errorf("expected 1 upstream call, got %d", got) + } +} + +func TestPropertyResolver_NotFound_NegativelyCached(t *testing.T) { + var calls int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&calls, 1) + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + r := newPropertyResolver(PropertyRegistryConfig{ + Endpoint: srv.URL, + CacheTTLSeconds: 60, + NegativeCacheTTLSeconds: 60, + CacheSize: 16, + TimeoutMs: 500, + }, nil) + + ctx := context.Background() + for i := range 3 { + rec, ok, err := r.Resolve(ctx, "nowhere.example") + if err != nil { + t.Fatalf("resolve[%d]: %v", i, err) + } + if ok || rec != nil { + t.Fatalf("resolve[%d]: expected not-found, got rec=%+v ok=%v", i, rec, ok) + } + } + if got := atomic.LoadInt32(&calls); got != 1 { + t.Errorf("expected 1 upstream call (rest served from negative cache), got %d", got) + } +} + +func TestPropertyResolver_UpstreamError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + r := newPropertyResolver(PropertyRegistryConfig{ + Endpoint: srv.URL, + CacheSize: 4, + TimeoutMs: 500, + }, nil) + _, _, err := r.Resolve(context.Background(), "x.example") + if err == nil { + t.Fatal("expected error on 500") + } +} + +func TestPropertyResolver_BearerAuth(t *testing.T) { + var sawAuth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sawAuth = r.Header.Get("Authorization") + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + r := newPropertyResolver(PropertyRegistryConfig{ + Endpoint: srv.URL, + AuthBearer: "secret-token", + NegativeCacheTTLSeconds: 60, + CacheSize: 4, + TimeoutMs: 500, + }, nil) + _, _, _ = r.Resolve(context.Background(), "x.example") + if sawAuth != "Bearer secret-token" { + t.Errorf("Authorization header = %q, want %q", sawAuth, "Bearer secret-token") + } +} + +// Trigger LRU eviction to make sure the cache does not grow unbounded. +func TestPropertyResolver_LRUEviction(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + domain := r.URL.Query().Get("domain") + _ = json.NewEncoder(w).Encode(map[string]any{ + "property": map[string]any{ + "property_rid": "rid-" + domain, + "property_id": domain, + "property_type": "website", + "domain": domain, + }, + }) + })) + defer srv.Close() + + r := newPropertyResolver(PropertyRegistryConfig{ + Endpoint: srv.URL, + CacheTTLSeconds: 60, + CacheSize: 2, + TimeoutMs: 500, + }, nil) + + ctx := context.Background() + for i := range 5 { + if _, _, err := r.Resolve(ctx, fmt.Sprintf("d%d.example", i)); err != nil { + t.Fatalf("resolve[%d]: %v", i, err) + } + } + if r.order.Len() > 2 { + t.Errorf("cache size = %d, want <= 2", r.order.Len()) + } +} diff --git a/modules/adcontextprotocol/tmp/provider_client.go b/modules/adcontextprotocol/tmp/provider_client.go new file mode 100644 index 00000000000..1eb0d99d699 --- /dev/null +++ b/modules/adcontextprotocol/tmp/provider_client.go @@ -0,0 +1,96 @@ +package tmp + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + + "github.com/adcontextprotocol/adcp-go/tmproto" + "github.com/prebid/prebid-server/v4/util/jsonutil" +) + +// callContext signs and POSTs a ContextMatch request to the provider's context +// endpoint. Signatures are computed per-provider-endpoint per the TMP spec. +func (m *Module) callContext(ctx context.Context, p ProviderConfig, req *tmproto.ContextMatchRequest) (*tmproto.ContextMatchResponse, error) { + epoch := tmproto.CurrentEpoch() + endpoint := tmproto.NormalizeProviderEndpointURL(p.ContextURL) + sig := m.signer.SignContextMatch(req, endpoint, epoch) + + raw, err := jsonutil.Marshal(req) + if err != nil { + return nil, fmt.Errorf("context marshal: %w", err) + } + + body, err := m.doTMP(ctx, p.ContextURL, raw, sig) + if err != nil { + return nil, err + } + var resp tmproto.ContextMatchResponse + if err := jsonutil.Unmarshal(body, &resp); err != nil { + return nil, fmt.Errorf("context decode: %w", err) + } + return &resp, nil +} + +// callIdentity signs and POSTs an IdentityMatch request to the provider's +// identity endpoint. The wire request keeps the Country field, but signing +// strips it via BuildIdentityMatchSigningInput's canonical form. +func (m *Module) callIdentity(ctx context.Context, p ProviderConfig, req *tmproto.IdentityMatchRequest) (*tmproto.IdentityMatchResponse, error) { + epoch := tmproto.CurrentEpoch() + endpoint := tmproto.NormalizeProviderEndpointURL(p.IdentityURL) + sig, err := m.signer.SignIdentityMatch(req, endpoint, epoch) + if err != nil { + return nil, fmt.Errorf("identity sign: %w", err) + } + + raw, err := jsonutil.Marshal(req) + if err != nil { + return nil, fmt.Errorf("identity marshal: %w", err) + } + + body, err := m.doTMP(ctx, p.IdentityURL, raw, sig) + if err != nil { + return nil, err + } + var resp tmproto.IdentityMatchResponse + if err := jsonutil.Unmarshal(body, &resp); err != nil { + return nil, fmt.Errorf("identity decode: %w", err) + } + return &resp, nil +} + +// doTMP sends a signed TMP request and returns the raw response body. A +// non-2xx response is surfaced as an error containing the provider's error +// envelope when parseable, so callers can distinguish "unknown package" from +// "provider unavailable". +func (m *Module) doTMP(ctx context.Context, url string, body []byte, signature string) ([]byte, error) { + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return nil, err + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set(tmproto.HeaderTMPSignature, signature) + httpReq.Header.Set(tmproto.HeaderTMPKeyID, m.signer.KeyID) + + resp, err := m.http.Do(httpReq) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read: %w", err) + } + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return respBody, nil + } + + var tmpErr tmproto.ErrorResponse + if err := jsonutil.Unmarshal(respBody, &tmpErr); err == nil && tmpErr.Code != "" { + return nil, fmt.Errorf("tmp status %d code=%s: %s", resp.StatusCode, tmpErr.Code, tmpErr.Message) + } + return nil, fmt.Errorf("tmp status %d", resp.StatusCode) +} diff --git a/modules/adcontextprotocol/tmp/router.go b/modules/adcontextprotocol/tmp/router.go new file mode 100644 index 00000000000..f41908fa369 --- /dev/null +++ b/modules/adcontextprotocol/tmp/router.go @@ -0,0 +1,195 @@ +package tmp + +import ( + "context" + "sync" + "time" + + "github.com/adcontextprotocol/adcp-go/tmproto" + "github.com/prebid/openrtb/v20/openrtb2" + "github.com/prebid/prebid-server/v4/logger" +) + +// providerResult holds one provider's contribution after both endpoints have +// been called (whichever were configured). Nil fields mean "not configured" or +// "call failed" — callers should treat both the same way when merging. +type providerResult struct { + Name string + Context *tmproto.ContextMatchResponse + Identity *tmproto.IdentityMatchResponse + Errs []error +} + +// routerResult is the joined view across all providers. +type routerResult struct { + Providers []providerResult + // Segments are the flat targeting strings the response hook writes into + // bid ext. Each string is "key=value" so consumers can split on the + // separator downstream. + Segments []string +} + +// fanOut executes the module's TMP flow for a single auction: adapt the bid +// request, resolve the property, then call every configured provider's +// context and identity endpoints in parallel. Returns quickly if the property +// cannot be resolved — the auction proceeds without TMP signals. +func (m *Module) fanOut(ctx context.Context, req *openrtb2.BidRequest) *routerResult { + inputs := deriveInputs(&m.cfg, req) + + // Domain / bundle → property_rid. + lookupKey := inputs.Domain + if lookupKey == "" { + lookupKey = inputs.Bundle + } + if lookupKey == "" { + return &routerResult{} + } + + prop, ok, err := m.registry.Resolve(ctx, lookupKey) + if err != nil { + logger.Warnf("adcontextprotocol.tmp: property registry lookup for %q failed: %v", lookupKey, err) + return &routerResult{} + } + if !ok || prop == nil || prop.PropertyRID == "" { + return &routerResult{} + } + propertyType := prop.PropertyType + if propertyType == "" { + propertyType = inputs.PropertyType + } + + // Apply masking before we let the ContextMatchRequest leave the process. + if m.cfg.Masking.Enabled { + maskedGeo := m.maskGeoMap(inputs.Geo) + if maskedGeo != nil { + inputs.Geo = maskedGeo + } + inputs.Identities = m.filterIdentities(inputs.Identities) + } + + ctxReq := &tmproto.ContextMatchRequest{ + Type: "context_match_request", + RequestID: newRequestID(), + PropertyRID: prop.PropertyRID, + PropertyID: prop.PropertyID, + PropertyType: propertyType, + PlacementID: inputs.PlacementID, + SellerAgentURL: m.cfg.SellerAgentURL, + Geo: inputs.Geo, + ArtifactRefs: inputs.ArtifactRefs, + } + + // Identity request stays absent when the auction has no usable tokens. + var idReq *tmproto.IdentityMatchRequest + if len(inputs.Identities) > 0 { + idReq = &tmproto.IdentityMatchRequest{ + Type: "identity_match_request", + RequestID: newRequestID(), + SellerAgentURL: m.cfg.SellerAgentURL, + Identities: inputs.Identities, + Consent: inputs.Consent, + Country: inputs.Country, + } + } + + results := make([]providerResult, len(m.cfg.Providers)) + var wg sync.WaitGroup + + for i, p := range m.cfg.Providers { + wg.Add(1) + go func(i int, p ProviderConfig) { + defer wg.Done() + res := providerResult{Name: p.Name} + + // Per-provider deadline; falls back to the module-level timeout. + timeout := time.Duration(p.TimeoutMs) * time.Millisecond + if timeout <= 0 { + timeout = time.Duration(m.cfg.TimeoutMs) * time.Millisecond + } + pCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + // Context and identity fire in parallel per provider so a slow + // endpoint on one side does not starve the other. + var innerWG sync.WaitGroup + var mu sync.Mutex + + if p.ContextURL != "" { + innerWG.Go(func() { + resp, err := m.callContext(pCtx, p, ctxReq) + mu.Lock() + defer mu.Unlock() + if err != nil { + res.Errs = append(res.Errs, err) + } else { + res.Context = resp + } + }) + } + if p.IdentityURL != "" && idReq != nil { + innerWG.Go(func() { + resp, err := m.callIdentity(pCtx, p, idReq) + mu.Lock() + defer mu.Unlock() + if err != nil { + res.Errs = append(res.Errs, err) + } else { + res.Identity = resp + } + }) + } + innerWG.Wait() + results[i] = res + }(i, p) + } + wg.Wait() + + return &routerResult{ + Providers: results, + Segments: mergeSegments(results), + } +} + +// mergeSegments joins each provider's context offers with its identity +// eligibility and flattens the survivors into "key=value" strings suitable +// for prebid targeting. Response-level signals from the context response are +// passed through as targeting keys directly. +func mergeSegments(results []providerResult) []string { + var out []string + for _, r := range results { + if r.Context == nil { + continue + } + eligible := eligibilitySet(r.Identity) + filterEligibility := r.Identity != nil + + for _, offer := range r.Context.Offers { + if filterEligibility { + if !eligible[offer.PackageID] { + continue + } + } + out = append(out, r.Name+"_package="+offer.PackageID) + } + + for k, v := range r.Context.Signals { + s, ok := v.(string) + if !ok { + continue + } + out = append(out, r.Name+"_"+k+"="+s) + } + } + return out +} + +func eligibilitySet(idResp *tmproto.IdentityMatchResponse) map[string]bool { + if idResp == nil { + return nil + } + set := make(map[string]bool, len(idResp.EligiblePackageIDs)) + for _, id := range idResp.EligiblePackageIDs { + set[id] = true + } + return set +} diff --git a/modules/adcontextprotocol/tmp/router_test.go b/modules/adcontextprotocol/tmp/router_test.go new file mode 100644 index 00000000000..fa6f249f031 --- /dev/null +++ b/modules/adcontextprotocol/tmp/router_test.go @@ -0,0 +1,216 @@ +package tmp + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/adcontextprotocol/adcp-go/tmproto" + "github.com/prebid/openrtb/v20/openrtb2" +) + +// tmpFixture spins up an in-memory property registry and a fake TMP provider +// that answers both /context and /identity, and returns a Module wired to them. +// Callers customize the handlers via the returned pointers. +type tmpFixture struct { + Module *Module + Registry *httptest.Server + Provider *httptest.Server + ContextHandler http.HandlerFunc + IdentHandler http.HandlerFunc +} + +func newFixture(t *testing.T) *tmpFixture { + t.Helper() + f := &tmpFixture{} + f.Registry = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + domain := r.URL.Query().Get("domain") + _ = json.NewEncoder(w).Encode(map[string]any{ + "property": map[string]any{ + "property_rid": "01916f3a-1234-7000-8000-000000000001", + "property_id": "fixture", + "property_type": "website", + "domain": domain, + }, + }) + })) + f.Provider = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/context": + if f.ContextHandler != nil { + f.ContextHandler(w, r) + return + } + _ = json.NewEncoder(w).Encode(tmproto.ContextMatchResponse{ + Type: "context_match_response", + RequestID: "req", + Offers: []tmproto.Offer{{PackageID: "pkg-a"}, {PackageID: "pkg-b"}}, + Signals: map[string]any{"segment": "auto_intender"}, + }) + case "/identity": + if f.IdentHandler != nil { + f.IdentHandler(w, r) + return + } + _ = json.NewEncoder(w).Encode(tmproto.IdentityMatchResponse{ + Type: "identity_match_response", + RequestID: "req", + EligiblePackageIDs: []string{"pkg-a"}, + ServeWindowSec: 60, + }) + default: + http.NotFound(w, r) + } + })) + + cfg := Config{ + SellerAgentURL: "https://seller.example.com", + Signing: SigningConfig{ + KeyID: "kid-1", + PrivateKeyPEM: genTestKey(t), + }, + PropertyRegistry: PropertyRegistryConfig{Endpoint: f.Registry.URL}, + Providers: []ProviderConfig{{ + Name: "prov", + IdentityURL: f.Provider.URL + "/identity", + ContextURL: f.Provider.URL + "/context", + }}, + } + priv, err := cfg.validated() + if err != nil { + t.Fatalf("validated: %v", err) + } + signer, err := tmproto.NewSigner(cfg.Signing.KeyID, priv) + if err != nil { + t.Fatalf("signer: %v", err) + } + f.Module = &Module{ + cfg: cfg, + signer: signer, + http: http.DefaultClient, + registry: newPropertyResolver(cfg.PropertyRegistry, nil), + } + return f +} + +func (f *tmpFixture) Close() { + f.Registry.Close() + f.Provider.Close() +} + +func sampleBidRequest() *openrtb2.BidRequest { + return &openrtb2.BidRequest{ + Site: &openrtb2.Site{Domain: "publisher.example", Page: "https://publisher.example/story"}, + Imp: []openrtb2.Imp{{TagID: "slot-1"}}, + User: &openrtb2.User{ + EIDs: []openrtb2.EID{ + {Source: "liveramp.com", UIDs: []openrtb2.UID{{ID: "ramp-x"}}}, + }, + }, + Device: &openrtb2.Device{ + Geo: &openrtb2.Geo{Country: "US"}, + }, + } +} + +func TestFanOut_JoinsContextAndIdentity(t *testing.T) { + f := newFixture(t) + defer f.Close() + + res := f.Module.fanOut(context.Background(), sampleBidRequest()) + if res == nil || len(res.Segments) == 0 { + t.Fatalf("expected segments, got %+v", res) + } + // pkg-b should be filtered out because identity only returned pkg-a. + sawPkgA := false + sawPkgB := false + for _, s := range res.Segments { + if s == "prov_package=pkg-a" { + sawPkgA = true + } + if s == "prov_package=pkg-b" { + sawPkgB = true + } + } + if !sawPkgA { + t.Errorf("expected prov_package=pkg-a in segments; got %v", res.Segments) + } + if sawPkgB { + t.Errorf("pkg-b should have been filtered by identity eligibility; got %v", res.Segments) + } +} + +func TestFanOut_ContextOnlyWhenNoIdentityTokens(t *testing.T) { + f := newFixture(t) + defer f.Close() + + req := sampleBidRequest() + req.User = nil // no eids + + res := f.Module.fanOut(context.Background(), req) + if res == nil || len(res.Segments) == 0 { + t.Fatalf("expected segments even without identity; got %+v", res) + } + // Both packages should be present because identity eligibility is not enforced. + sawA, sawB := false, false + for _, s := range res.Segments { + if s == "prov_package=pkg-a" { + sawA = true + } + if s == "prov_package=pkg-b" { + sawB = true + } + } + if !sawA || !sawB { + t.Errorf("expected both packages without identity; got %v", res.Segments) + } +} + +func TestFanOut_UnknownDomainReturnsEmpty(t *testing.T) { + f := newFixture(t) + defer f.Close() + // Replace registry with a 404-only server. + f.Registry.Close() + f.Registry = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + f.Module.registry = newPropertyResolver(PropertyRegistryConfig{ + Endpoint: f.Registry.URL, + NegativeCacheTTLSeconds: 60, + CacheSize: 4, + TimeoutMs: 500, + }, nil) + + res := f.Module.fanOut(context.Background(), sampleBidRequest()) + if res == nil { + t.Fatal("expected non-nil result") + } + if len(res.Segments) != 0 { + t.Errorf("expected empty segments for unknown domain; got %v", res.Segments) + } +} + +func TestFanOut_SigningHeadersOnOutbound(t *testing.T) { + f := newFixture(t) + defer f.Close() + + var sawSig, sawKid string + f.ContextHandler = func(w http.ResponseWriter, r *http.Request) { + sawSig = r.Header.Get(tmproto.HeaderTMPSignature) + sawKid = r.Header.Get(tmproto.HeaderTMPKeyID) + _ = json.NewEncoder(w).Encode(tmproto.ContextMatchResponse{Type: "context_match_response", Offers: []tmproto.Offer{{PackageID: "pkg"}}}) + } + f.IdentHandler = func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(tmproto.IdentityMatchResponse{Type: "identity_match_response", EligiblePackageIDs: []string{"pkg"}}) + } + + _ = f.Module.fanOut(context.Background(), sampleBidRequest()) + if sawSig == "" { + t.Error("expected X-AdCP-Signature to be set on outbound context call") + } + if sawKid != "kid-1" { + t.Errorf("X-AdCP-Key-Id = %q, want kid-1", sawKid) + } +} diff --git a/modules/builder.go b/modules/builder.go index df2f1b1c3a7..8c129a96279 100644 --- a/modules/builder.go +++ b/modules/builder.go @@ -1,6 +1,7 @@ package modules import ( + adcontextprotocolTmp "github.com/prebid/prebid-server/v4/modules/adcontextprotocol/tmp" fiftyonedegreesDevicedetection "github.com/prebid/prebid-server/v4/modules/fiftyonedegrees/devicedetection" prebidOrtb2blocking "github.com/prebid/prebid-server/v4/modules/prebid/ortb2blocking" prebidRulesengine "github.com/prebid/prebid-server/v4/modules/prebid/rulesengine" @@ -12,6 +13,9 @@ import ( // vendor and module names are chosen based on the module directory name func builders() ModuleBuilders { return ModuleBuilders{ + "adcontextprotocol": { + "tmp": adcontextprotocolTmp.Builder, + }, "fiftyonedegrees": { "devicedetection": fiftyonedegreesDevicedetection.Builder, }, From c135ef46b05b77a2577fa48f6f7e44e448e4d1b5 Mon Sep 17 00:00:00 2001 From: Oleksandr Halushchak Date: Wed, 15 Jul 2026 14:02:27 +0200 Subject: [PATCH 02/11] Address review feedback: panic containment, timeout semantics, correctness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Recover panics in per-provider fan-out goroutines and per-endpoint inner goroutines. A crashing provider client no longer takes the process down; the error is appended to the provider result and the merge stays consistent. - Drop the client-level http.Client.Timeout so per-provider TimeoutMs can exceed the module default. Per-call deadlines come from context. - Short-circuit the fan-out when the OpenRTB request lacks a placement id — ContextMatch requires it and firing without one wastes a signed call every well-behaved provider would 400. - Return an error from newRequestID rather than falling back to a literal string. Reusing a fallback would silently violate the TMP privacy invariant that context and identity request_ids never correlate. - Bound response reads on the provider and registry clients so a misbehaving upstream cannot exhaust memory. - Give the property registry singleflight leader a fresh context so followers are not tied to whichever caller happened to arrive first. - Drain non-2xx registry response bodies to preserve keep-alive reuse. - Own a cancelable context in the entrypoint hook so the fan-out goroutine is guaranteed to stop when the auction ends. - Mirror segments onto seatbid[].bid[].ext.prebid.targeting when add_to_targeting is set — that is where GAM actually reads keys. - Stringify context response signals with fmt.Sprint so non-string values (numbers, bools) survive rather than being silently dropped. - Fix DefaultPropertyType docs / behavior mismatch — OpenRTB auto-detect wins over the operator default; default only applies when neither Site nor App is present. - Allocate a fresh slice in filterIdentities so future callers that reuse the input slice do not see mutated content. - Reject empty keys in splitKV so a malformed segment does not produce invalid sjson paths. - Drop the unused cache_ttl_seconds config field. - Add tests for the placement short-circuit and provider-error paths. --- modules/adcontextprotocol/tmp/README.md | 1 - modules/adcontextprotocol/tmp/adapter.go | 34 ++++++----- modules/adcontextprotocol/tmp/config.go | 7 --- modules/adcontextprotocol/tmp/config_test.go | 3 - modules/adcontextprotocol/tmp/hooks.go | 61 +++++++++++++------ modules/adcontextprotocol/tmp/masking.go | 2 +- modules/adcontextprotocol/tmp/module.go | 12 +++- .../tmp/property_registry.go | 19 ++++-- .../adcontextprotocol/tmp/provider_client.go | 5 +- modules/adcontextprotocol/tmp/router.go | 49 +++++++++++++-- modules/adcontextprotocol/tmp/router_test.go | 46 ++++++++++++++ 11 files changed, 181 insertions(+), 58 deletions(-) diff --git a/modules/adcontextprotocol/tmp/README.md b/modules/adcontextprotocol/tmp/README.md index b09a78aff1b..d148a95fe38 100644 --- a/modules/adcontextprotocol/tmp/README.md +++ b/modules/adcontextprotocol/tmp/README.md @@ -44,7 +44,6 @@ hooks: context_url: https://tmp.example.com/context timeout_ms: 200 timeout_ms: 300 - cache_ttl_seconds: 60 targeting_key: adcp add_to_targeting: false masking: diff --git a/modules/adcontextprotocol/tmp/adapter.go b/modules/adcontextprotocol/tmp/adapter.go index 511f0fed9fd..aee67770d6d 100644 --- a/modules/adcontextprotocol/tmp/adapter.go +++ b/modules/adcontextprotocol/tmp/adapter.go @@ -45,11 +45,17 @@ func deriveInputs(cfg *Config, req *openrtb2.BidRequest) tmpInputs { if req.App != nil { out.Bundle = req.App.Bundle } - if def := cfg.DefaultPropertyType; def != "" { - out.PropertyType = tmproto.PropertyType(def) - } else if req.App != nil { + // Prefer OpenRTB auto-detect; fall back to operator default only when the + // request carries neither Site nor App. The registry response takes final + // priority (applied by the router) — this value is only a fallback. + switch { + case req.App != nil: out.PropertyType = tmproto.PropertyTypeMobileApp - } else { + case req.Site != nil: + out.PropertyType = tmproto.PropertyTypeWebsite + case cfg.DefaultPropertyType != "": + out.PropertyType = tmproto.PropertyType(cfg.DefaultPropertyType) + default: out.PropertyType = tmproto.PropertyTypeWebsite } @@ -130,16 +136,14 @@ func extractIdentities(user *openrtb2.User) []tmproto.IdentityToken { if uidType == "" { continue } - p, ok := priority[eid.Source] - if !ok { - p = 100 - } + // Every source that survives mapEIDToUIDType has a priority entry; + // the map fallback is unreachable. candidates = append(candidates, scored{ tok: tmproto.IdentityToken{ UIDType: uidType, UserToken: eid.UIDs[0].ID, }, - score: p, + score: priority[eid.Source], }) } @@ -202,13 +206,13 @@ func extractConsent(req *openrtb2.BidRequest) map[string]any { } // newRequestID returns a UUID v4 formatted for the TMP wire. -func newRequestID() string { +// Callers MUST propagate the error rather than reusing a fallback ID: TMP +// requires that context and identity request_ids never correlate, and reusing +// a deterministic id would silently break that invariant. +func newRequestID() (string, error) { u, err := uuid.NewV4() if err != nil { - // Extremely unlikely (would need a broken RNG). Fall back to a - // deterministic identifier — signature verification is unaffected but - // dedup at the provider may be weaker on repeats. - return "adcp-tmp-request" + return "", err } - return u.String() + return u.String(), nil } diff --git a/modules/adcontextprotocol/tmp/config.go b/modules/adcontextprotocol/tmp/config.go index 2706759f0bb..a42ec51c06b 100644 --- a/modules/adcontextprotocol/tmp/config.go +++ b/modules/adcontextprotocol/tmp/config.go @@ -23,10 +23,6 @@ type Config struct { // can override with their own Timeout field. Default 300 ms. TimeoutMs int `json:"timeout_ms"` - // CacheTTLSeconds is the TTL for the local response cache used when the - // provider does not return an explicit cache_ttl. Default 60 s. - CacheTTLSeconds int `json:"cache_ttl_seconds"` - // Signing holds the Ed25519 key used to authenticate outbound requests to // TMP providers. Required. Signing SigningConfig `json:"signing"` @@ -156,9 +152,6 @@ func (c *Config) validated() (ed25519.PrivateKey, error) { if c.TimeoutMs <= 0 { c.TimeoutMs = 300 } - if c.CacheTTLSeconds <= 0 { - c.CacheTTLSeconds = 60 - } if c.PropertyRegistry.CacheTTLSeconds <= 0 { c.PropertyRegistry.CacheTTLSeconds = 3600 } diff --git a/modules/adcontextprotocol/tmp/config_test.go b/modules/adcontextprotocol/tmp/config_test.go index c5dbf1d0b5d..fbfd0faf63b 100644 --- a/modules/adcontextprotocol/tmp/config_test.go +++ b/modules/adcontextprotocol/tmp/config_test.go @@ -54,9 +54,6 @@ func TestValidated_Defaults(t *testing.T) { if cfg.TimeoutMs != 300 { t.Errorf("TimeoutMs default = %d, want 300", cfg.TimeoutMs) } - if cfg.CacheTTLSeconds != 60 { - t.Errorf("CacheTTLSeconds default = %d, want 60", cfg.CacheTTLSeconds) - } if cfg.PropertyRegistry.CacheTTLSeconds != 3600 { t.Errorf("PropertyRegistry.CacheTTLSeconds default = %d, want 3600", cfg.PropertyRegistry.CacheTTLSeconds) } diff --git a/modules/adcontextprotocol/tmp/hooks.go b/modules/adcontextprotocol/tmp/hooks.go index 7075b8a77e9..b7db3256123 100644 --- a/modules/adcontextprotocol/tmp/hooks.go +++ b/modules/adcontextprotocol/tmp/hooks.go @@ -6,24 +6,33 @@ import ( "github.com/prebid/prebid-server/v4/hooks/hookanalytics" "github.com/prebid/prebid-server/v4/hooks/hookstage" "github.com/prebid/prebid-server/v4/logger" + "github.com/prebid/prebid-server/v4/util/iterutil" "github.com/tidwall/sjson" ) -// HandleEntrypointHook allocates the per-auction async request holder. +// HandleEntrypointHook allocates the per-auction async request holder along +// with a cancelable context that survives across hook stages. The response +// hook cancels it on the way out so an in-flight fan-out does not outlive the +// auction. func (m *Module) HandleEntrypointHook( - _ context.Context, + ctx context.Context, _ hookstage.ModuleInvocationContext, _ hookstage.EntrypointPayload, ) (hookstage.HookResult[hookstage.EntrypointPayload], error) { + fanoutCtx, cancel := context.WithCancel(ctx) moduleContext := hookstage.NewModuleContext() - moduleContext.Set(asyncKey, &asyncRequest{done: make(chan struct{})}) + moduleContext.Set(asyncKey, &asyncRequest{ + done: make(chan struct{}), + ctx: fanoutCtx, + cancel: cancel, + }) return hookstage.HookResult[hookstage.EntrypointPayload]{ModuleContext: moduleContext}, nil } // HandleProcessedAuctionHook kicks off the TMP fan-out in the background. The // auction continues immediately; results are collected in the response hook. func (m *Module) HandleProcessedAuctionHook( - ctx context.Context, + _ context.Context, miCtx hookstage.ModuleInvocationContext, payload hookstage.ProcessedAuctionRequestPayload, ) (hookstage.HookResult[hookstage.ProcessedAuctionRequestPayload], error) { @@ -46,7 +55,7 @@ func (m *Module) HandleProcessedAuctionHook( } close(async.done) }() - async.result = m.fanOut(ctx, bidRequest) + async.result = m.fanOut(async.ctx, bidRequest) }() return res, nil } @@ -63,6 +72,9 @@ func (m *Module) HandleAuctionResponseHook( if !ok { return res, nil } + // Cancelling here releases the fan-out goroutine if it is still running + // past the response window — no orphan goroutines beyond the auction. + defer async.cancel() select { case <-async.done: case <-ctx.Done(): @@ -84,21 +96,31 @@ func (m *Module) HandleAuctionResponseHook( } else { ext = newExt } + payload.BidResponse.Ext = ext + + // Per-bid targeting is where GAM et al actually read keys, so + // mirror the response-level segments onto each bid's ext when + // enabled. if addToTargeting { - for _, s := range segments { - kv := splitKV(s) - if kv == nil { - continue - } - newExt, err := sjson.SetBytes(ext, "prebid.targeting."+kv[0], kv[1]) - if err != nil { - logger.Errorf("adcontextprotocol.tmp: targeting set: %v", err) - continue + for seatBid := range iterutil.SlicePointerValues(payload.BidResponse.SeatBid) { + for bid := range iterutil.SlicePointerValues(seatBid.Bid) { + bidExt := bid.Ext + for _, s := range segments { + kv := splitKV(s) + if kv == nil { + continue + } + updated, err := sjson.SetBytes(bidExt, "prebid.targeting."+kv[0], kv[1]) + if err != nil { + logger.Errorf("adcontextprotocol.tmp: bid targeting set: %v", err) + continue + } + bidExt = updated + } + bid.Ext = bidExt } - ext = newExt } } - payload.BidResponse.Ext = ext return payload, nil }, hookstage.MutationUpdate, @@ -127,10 +149,13 @@ func (m *Module) loadAsync(miCtx hookstage.ModuleInvocationContext) (*asyncReque return a, ok } -// splitKV splits "key=value" once; returns nil for malformed input. +// splitKV splits "key=value" on the first '=' and rejects empty keys. func splitKV(s string) []string { - for i := 0; i < len(s); i++ { + for i := range len(s) { if s[i] == '=' { + if i == 0 { + return nil + } return []string{s[:i], s[i+1:]} } } diff --git a/modules/adcontextprotocol/tmp/masking.go b/modules/adcontextprotocol/tmp/masking.go index 03c3e86a936..d246b72c1d7 100644 --- a/modules/adcontextprotocol/tmp/masking.go +++ b/modules/adcontextprotocol/tmp/masking.go @@ -51,7 +51,7 @@ func (m *Module) filterIdentities(tokens []tmproto.IdentityToken) []tmproto.Iden allowed[t] = true } } - out := tokens[:0] + out := make([]tmproto.IdentityToken, 0, len(tokens)) for _, t := range tokens { if allowed[t.UIDType] { out = append(out, t) diff --git a/modules/adcontextprotocol/tmp/module.go b/modules/adcontextprotocol/tmp/module.go index 331dd0d654a..e42b838eea0 100644 --- a/modules/adcontextprotocol/tmp/module.go +++ b/modules/adcontextprotocol/tmp/module.go @@ -9,10 +9,10 @@ package tmp import ( + "context" "encoding/json" "fmt" "net/http" - "time" "github.com/adcontextprotocol/adcp-go/tmproto" "github.com/prebid/prebid-server/v4/hooks/hookstage" @@ -37,8 +37,10 @@ func Builder(raw json.RawMessage, deps moduledeps.ModuleDeps) (any, error) { return nil, fmt.Errorf("adcontextprotocol.tmp: signer: %w", err) } + // No client-level Timeout: per-call deadlines come from context so that + // per-provider TimeoutMs (which can exceed the module default) actually + // applies rather than being clipped by an overall client timeout. httpClient := &http.Client{ - Timeout: time.Duration(cfg.TimeoutMs) * time.Millisecond, Transport: deps.HTTPClient.Transport, } @@ -70,9 +72,13 @@ var ( ) // asyncRequest carries a single auction's in-flight TMP fan-out from the -// entrypoint hook through to the response hook. +// entrypoint hook through to the response hook. ctx / cancel are owned here +// (not the hook's own ctx) so the response hook can guarantee no orphan +// goroutine survives the auction. type asyncRequest struct { done chan struct{} + ctx context.Context + cancel context.CancelFunc result *routerResult err error } diff --git a/modules/adcontextprotocol/tmp/property_registry.go b/modules/adcontextprotocol/tmp/property_registry.go index bd78f7785a6..2cb8d3ff1e7 100644 --- a/modules/adcontextprotocol/tmp/property_registry.go +++ b/modules/adcontextprotocol/tmp/property_registry.go @@ -77,9 +77,15 @@ func (p *propertyResolver) Resolve(ctx context.Context, domain string) (*Propert return rec, ok, nil } - // Single-flight: collapse concurrent misses for the same domain onto one HTTP call. + // Single-flight: collapse concurrent misses for the same domain onto one + // HTTP call. The leader's fetch runs in a fresh context so followers are + // not tied to whichever caller happened to arrive first — if that caller's + // auction times out, the leader keeps going and future callers get the + // result from cache. rec, err := p.single.do(key, func() (*PropertyRecord, error) { - return p.fetch(ctx, key) + leaderCtx, cancel := context.WithTimeout(context.Background(), time.Duration(p.cfg.TimeoutMs)*time.Millisecond) + defer cancel() + return p.fetch(leaderCtx, key) }) if err != nil { return nil, false, err @@ -114,11 +120,16 @@ func (p *propertyResolver) fetch(ctx context.Context, domain string) (*PropertyR if err != nil { return nil, err } - defer func() { _ = resp.Body.Close() }() + defer func() { + // Drain and close so keep-alive can reuse the connection. + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<16)) + _ = resp.Body.Close() + }() switch resp.StatusCode { case http.StatusOK: - raw, err := io.ReadAll(resp.Body) + // 64 KiB is generous for a single property record. + raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<16)) if err != nil { return nil, fmt.Errorf("registry read: %w", err) } diff --git a/modules/adcontextprotocol/tmp/provider_client.go b/modules/adcontextprotocol/tmp/provider_client.go index 1eb0d99d699..8c016ef173e 100644 --- a/modules/adcontextprotocol/tmp/provider_client.go +++ b/modules/adcontextprotocol/tmp/provider_client.go @@ -80,7 +80,10 @@ func (m *Module) doTMP(ctx context.Context, url string, body []byte, signature s } defer func() { _ = resp.Body.Close() }() - respBody, err := io.ReadAll(resp.Body) + // Cap the response so a misbehaving upstream cannot exhaust memory on the + // auction hot path. The TMP spec targets 200–600 B messages, so 1 MiB is + // far more than any legitimate response. + respBody, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if err != nil { return nil, fmt.Errorf("read: %w", err) } diff --git a/modules/adcontextprotocol/tmp/router.go b/modules/adcontextprotocol/tmp/router.go index f41908fa369..d7bb4172bc9 100644 --- a/modules/adcontextprotocol/tmp/router.go +++ b/modules/adcontextprotocol/tmp/router.go @@ -2,6 +2,7 @@ package tmp import ( "context" + "fmt" "sync" "time" @@ -44,6 +45,11 @@ func (m *Module) fanOut(ctx context.Context, req *openrtb2.BidRequest) *routerRe if lookupKey == "" { return &routerResult{} } + // PlacementID is a required TMP context field. Firing without one produces + // a payload every well-behaved provider will 400, so short-circuit here. + if inputs.PlacementID == "" { + return &routerResult{} + } prop, ok, err := m.registry.Resolve(ctx, lookupKey) if err != nil { @@ -67,9 +73,14 @@ func (m *Module) fanOut(ctx context.Context, req *openrtb2.BidRequest) *routerRe inputs.Identities = m.filterIdentities(inputs.Identities) } + ctxRequestID, err := newRequestID() + if err != nil { + logger.Errorf("adcontextprotocol.tmp: request id generation failed: %v", err) + return &routerResult{} + } ctxReq := &tmproto.ContextMatchRequest{ Type: "context_match_request", - RequestID: newRequestID(), + RequestID: ctxRequestID, PropertyRID: prop.PropertyRID, PropertyID: prop.PropertyID, PropertyType: propertyType, @@ -80,11 +91,18 @@ func (m *Module) fanOut(ctx context.Context, req *openrtb2.BidRequest) *routerRe } // Identity request stays absent when the auction has no usable tokens. + // A separate request_id is generated to preserve the TMP privacy + // invariant that context and identity ids MUST NOT correlate. var idReq *tmproto.IdentityMatchRequest if len(inputs.Identities) > 0 { + idRequestID, err := newRequestID() + if err != nil { + logger.Errorf("adcontextprotocol.tmp: request id generation failed: %v", err) + return &routerResult{} + } idReq = &tmproto.IdentityMatchRequest{ Type: "identity_match_request", - RequestID: newRequestID(), + RequestID: idRequestID, SellerAgentURL: m.cfg.SellerAgentURL, Identities: inputs.Identities, Consent: inputs.Consent, @@ -99,6 +117,12 @@ func (m *Module) fanOut(ctx context.Context, req *openrtb2.BidRequest) *routerRe wg.Add(1) go func(i int, p ProviderConfig) { defer wg.Done() + defer func() { + if r := recover(); r != nil { + logger.Errorf("adcontextprotocol.tmp: panic in provider %s fan-out: %v", p.Name, r) + results[i] = providerResult{Name: p.Name, Errs: []error{fmt.Errorf("panic: %v", r)}} + } + }() res := providerResult{Name: p.Name} // Per-provider deadline; falls back to the module-level timeout. @@ -116,6 +140,14 @@ func (m *Module) fanOut(ctx context.Context, req *openrtb2.BidRequest) *routerRe if p.ContextURL != "" { innerWG.Go(func() { + defer func() { + if r := recover(); r != nil { + mu.Lock() + res.Errs = append(res.Errs, fmt.Errorf("panic in context call: %v", r)) + mu.Unlock() + logger.Errorf("adcontextprotocol.tmp: panic in context call to %s: %v", p.Name, r) + } + }() resp, err := m.callContext(pCtx, p, ctxReq) mu.Lock() defer mu.Unlock() @@ -128,6 +160,14 @@ func (m *Module) fanOut(ctx context.Context, req *openrtb2.BidRequest) *routerRe } if p.IdentityURL != "" && idReq != nil { innerWG.Go(func() { + defer func() { + if r := recover(); r != nil { + mu.Lock() + res.Errs = append(res.Errs, fmt.Errorf("panic in identity call: %v", r)) + mu.Unlock() + logger.Errorf("adcontextprotocol.tmp: panic in identity call to %s: %v", p.Name, r) + } + }() resp, err := m.callIdentity(pCtx, p, idReq) mu.Lock() defer mu.Unlock() @@ -173,11 +213,10 @@ func mergeSegments(results []providerResult) []string { } for k, v := range r.Context.Signals { - s, ok := v.(string) - if !ok { + if v == nil { continue } - out = append(out, r.Name+"_"+k+"="+s) + out = append(out, r.Name+"_"+k+"="+fmt.Sprint(v)) } } return out diff --git a/modules/adcontextprotocol/tmp/router_test.go b/modules/adcontextprotocol/tmp/router_test.go index fa6f249f031..49a556effc2 100644 --- a/modules/adcontextprotocol/tmp/router_test.go +++ b/modules/adcontextprotocol/tmp/router_test.go @@ -192,6 +192,52 @@ func TestFanOut_UnknownDomainReturnsEmpty(t *testing.T) { } } +func TestFanOut_EmptyPlacementIDShortCircuits(t *testing.T) { + f := newFixture(t) + defer f.Close() + + req := sampleBidRequest() + req.Imp = nil + + res := f.Module.fanOut(context.Background(), req) + if res == nil { + t.Fatal("expected non-nil result") + } + if len(res.Segments) != 0 { + t.Errorf("expected empty segments without a placement id; got %v", res.Segments) + } +} + +func TestFanOut_ProviderPanicRecovered(t *testing.T) { + f := newFixture(t) + defer f.Close() + + // Make the provider hang up mid-response so JSON decode panics on some + // corrupt payload — but more simply, close the connection. + f.ContextHandler = func(w http.ResponseWriter, r *http.Request) { + panic("simulated context handler panic") + } + f.IdentHandler = func(w http.ResponseWriter, r *http.Request) { + panic("simulated identity handler panic") + } + + // httptest recovers server-side panics, so this only exercises client-side + // recovery when the response body is malformed. We swap in a handler that + // returns garbage JSON that decodes to an empty struct, and verify the + // module keeps returning a non-nil routerResult (i.e. no crash). + f.ContextHandler = func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("not json")) + } + f.IdentHandler = func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("not json")) + } + + res := f.Module.fanOut(context.Background(), sampleBidRequest()) + if res == nil { + t.Fatal("expected non-nil result even when both provider calls error") + } +} + func TestFanOut_SigningHeadersOnOutbound(t *testing.T) { f := newFixture(t) defer f.Close() From dbc77f207438264f91df6776207c500d49a9a4fa Mon Sep 17 00:00:00 2001 From: Oleksandr Halushchak Date: Wed, 15 Jul 2026 16:12:23 +0200 Subject: [PATCH 03/11] Randomize context / identity call order, add optional jitter The TMP spec recommends the publisher randomize order and delay the context and identity outbound calls to break timing correlation at a passive observer. Add both: - Ordering is always randomized per request (rand.Shuffle on the two closures). Zero cost, no config knob needed. - New DecorrelationMaxDelayMs config field (default 0 = off). When set, the second of the two calls sleeps for a uniform random duration in [0, N] ms before firing. Guarded against context cancellation so a ticking auction deadline still stops the wait promptly. Tests: verify both orderings appear across 200 iterations and that the default config path stays fast when the delay is disabled. --- modules/adcontextprotocol/tmp/README.md | 5 ++ modules/adcontextprotocol/tmp/config.go | 14 ++++ modules/adcontextprotocol/tmp/router.go | 27 +++++++- modules/adcontextprotocol/tmp/router_test.go | 67 ++++++++++++++++++++ 4 files changed, 110 insertions(+), 3 deletions(-) diff --git a/modules/adcontextprotocol/tmp/README.md b/modules/adcontextprotocol/tmp/README.md index d148a95fe38..60050eed7ae 100644 --- a/modules/adcontextprotocol/tmp/README.md +++ b/modules/adcontextprotocol/tmp/README.md @@ -44,6 +44,11 @@ hooks: context_url: https://tmp.example.com/context timeout_ms: 200 timeout_ms: 300 + # Set to a positive value to jitter the second of a provider's context / + # identity outbound calls by a random [0, N] ms, breaking timing + # correlation at a passive observer. Order of the two calls is always + # randomized regardless. + decorrelation_max_delay_ms: 0 targeting_key: adcp add_to_targeting: false masking: diff --git a/modules/adcontextprotocol/tmp/config.go b/modules/adcontextprotocol/tmp/config.go index a42ec51c06b..84209f839ff 100644 --- a/modules/adcontextprotocol/tmp/config.go +++ b/modules/adcontextprotocol/tmp/config.go @@ -23,6 +23,17 @@ type Config struct { // can override with their own Timeout field. Default 300 ms. TimeoutMs int `json:"timeout_ms"` + // DecorrelationMaxDelayMs, when > 0, jitters the second of a provider's + // context / identity outbound calls by a random duration in + // [0, DecorrelationMaxDelayMs] milliseconds. The pair is also spawned in + // a randomized order regardless of this value. Set to 0 to disable the + // delay (order randomization remains on — it is free). Default 0. + // + // Recommended by the TMP spec as a MAY to break timing correlation + // between the two calls at a passive observer. Costs latency on the + // auction hot path — operators trade privacy for speed by tuning this. + DecorrelationMaxDelayMs int `json:"decorrelation_max_delay_ms"` + // Signing holds the Ed25519 key used to authenticate outbound requests to // TMP providers. Required. Signing SigningConfig `json:"signing"` @@ -152,6 +163,9 @@ func (c *Config) validated() (ed25519.PrivateKey, error) { if c.TimeoutMs <= 0 { c.TimeoutMs = 300 } + if c.DecorrelationMaxDelayMs < 0 { + return nil, errors.New("decorrelation_max_delay_ms cannot be negative") + } if c.PropertyRegistry.CacheTTLSeconds <= 0 { c.PropertyRegistry.CacheTTLSeconds = 3600 } diff --git a/modules/adcontextprotocol/tmp/router.go b/modules/adcontextprotocol/tmp/router.go index d7bb4172bc9..0b911b2b937 100644 --- a/modules/adcontextprotocol/tmp/router.go +++ b/modules/adcontextprotocol/tmp/router.go @@ -3,6 +3,7 @@ package tmp import ( "context" "fmt" + "math/rand/v2" "sync" "time" @@ -134,12 +135,16 @@ func (m *Module) fanOut(ctx context.Context, req *openrtb2.BidRequest) *routerRe defer cancel() // Context and identity fire in parallel per provider so a slow - // endpoint on one side does not starve the other. + // endpoint on one side does not starve the other. Order is + // randomized every request and the second call is optionally + // jittered so a passive observer cannot rely on stable timing to + // pair the two. var innerWG sync.WaitGroup var mu sync.Mutex + var calls []func() if p.ContextURL != "" { - innerWG.Go(func() { + calls = append(calls, func() { defer func() { if r := recover(); r != nil { mu.Lock() @@ -159,7 +164,7 @@ func (m *Module) fanOut(ctx context.Context, req *openrtb2.BidRequest) *routerRe }) } if p.IdentityURL != "" && idReq != nil { - innerWG.Go(func() { + calls = append(calls, func() { defer func() { if r := recover(); r != nil { mu.Lock() @@ -178,6 +183,22 @@ func (m *Module) fanOut(ctx context.Context, req *openrtb2.BidRequest) *routerRe } }) } + + rand.Shuffle(len(calls), func(a, b int) { calls[a], calls[b] = calls[b], calls[a] }) + maxDelay := m.cfg.DecorrelationMaxDelayMs + for idx, call := range calls { + innerWG.Go(func() { + if idx > 0 && maxDelay > 0 { + delay := time.Duration(rand.IntN(maxDelay+1)) * time.Millisecond + select { + case <-time.After(delay): + case <-pCtx.Done(): + return + } + } + call() + }) + } innerWG.Wait() results[i] = res }(i, p) diff --git a/modules/adcontextprotocol/tmp/router_test.go b/modules/adcontextprotocol/tmp/router_test.go index 49a556effc2..77fd1657287 100644 --- a/modules/adcontextprotocol/tmp/router_test.go +++ b/modules/adcontextprotocol/tmp/router_test.go @@ -5,7 +5,9 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "sync" "testing" + "time" "github.com/adcontextprotocol/adcp-go/tmproto" "github.com/prebid/openrtb/v20/openrtb2" @@ -238,6 +240,71 @@ func TestFanOut_ProviderPanicRecovered(t *testing.T) { } } +func TestFanOut_RandomizesContextIdentityOrder(t *testing.T) { + f := newFixture(t) + defer f.Close() + + var mu sync.Mutex + seen := map[string]int{} // "context-first" / "identity-first" + // Track which endpoint each request hit; whichever handler fires first + // per iteration determines the order for that iteration. + var currentIteration string + setFirst := func(kind string) { + mu.Lock() + defer mu.Unlock() + if currentIteration == "" { + currentIteration = kind + } + } + f.ContextHandler = func(w http.ResponseWriter, r *http.Request) { + setFirst("context") + _ = json.NewEncoder(w).Encode(tmproto.ContextMatchResponse{Type: "context_match_response", Offers: []tmproto.Offer{{PackageID: "pkg"}}}) + } + f.IdentHandler = func(w http.ResponseWriter, r *http.Request) { + setFirst("identity") + _ = json.NewEncoder(w).Encode(tmproto.IdentityMatchResponse{Type: "identity_match_response", EligiblePackageIDs: []string{"pkg"}}) + } + + const iterations = 200 + for range iterations { + mu.Lock() + currentIteration = "" + mu.Unlock() + _ = f.Module.fanOut(context.Background(), sampleBidRequest()) + mu.Lock() + if currentIteration != "" { + seen[currentIteration+"-first"]++ + } + mu.Unlock() + } + + // Both orderings must appear at least once across 200 iterations. The + // probability of a single-ordering run is (1/2)^200 — effectively zero. + if seen["context-first"] == 0 { + t.Errorf("context never fired first across %d iterations; ordering is not randomized", iterations) + } + if seen["identity-first"] == 0 { + t.Errorf("identity never fired first across %d iterations; ordering is not randomized", iterations) + } +} + +func TestFanOut_DecorrelationDelayDisabledByDefault(t *testing.T) { + f := newFixture(t) + defer f.Close() + if f.Module.cfg.DecorrelationMaxDelayMs != 0 { + t.Errorf("expected default DecorrelationMaxDelayMs = 0 (off), got %d", f.Module.cfg.DecorrelationMaxDelayMs) + } + + start := time.Now() + _ = f.Module.fanOut(context.Background(), sampleBidRequest()) + elapsed := time.Since(start) + // With the delay off, a healthy in-process fixture should complete well + // under 100 ms. A generous bound catches regressions without being flaky. + if elapsed > 100*time.Millisecond { + t.Errorf("fan-out took %v with decorrelation disabled; want < 100ms", elapsed) + } +} + func TestFanOut_SigningHeadersOnOutbound(t *testing.T) { f := newFixture(t) defer f.Close() From 03a760a4b506d523921f62e43d01107f8611a3e0 Mon Sep 17 00:00:00 2001 From: Oleksandr Halushchak Date: Wed, 15 Jul 2026 21:21:06 +0200 Subject: [PATCH 04/11] Address Fable review: ctx lifetime, race, fail-closed, caps, wire masking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Correctness batch first (Fable found the module was silently broken): - Blocker #1 (fan-out ctx dies on hook return). The framework cancels each hook's own ctx the moment the hook returns (hooks/hookexecution/execution.go), so a fan-out rooted in an entrypoint hook's ctx was Done before the goroutine spawned. Consequence: every provider call started already-cancelled, zero segments landed, and the failure was silent (errors stayed inside providerResult.Errs and analytics reported Success). Removed the entrypoint hook entirely; the async holder now allocates inside HandleProcessedAuctionHook with a Background-rooted ctx, cancelled via defer async.cancel() in the response hook. That also fixes finding #6 (response hook stalling for its full group timeout when processed-auction never ran) — no holder ever means the response hook short-circuits cleanly. - Blocker #2 (data race on live BidRequest). The fan-out goroutine read Site/Imp/Device.Geo/User.EIDs and unmarshalled User.Ext while the auction continued to mutate the wrapper (RebuildRequest, privacy scrubbing, other modules). deriveInputs now runs synchronously on the caller's stack; the goroutine only sees the tmpInputs value snapshot and never touches the BidRequest. - Finding #10 (no hooks-level test). New hooks_test.go exercises HandleProcessedAuctionHook → HandleAuctionResponseHook through real hookstage plumbing, including simulating the framework's per-hook ctx cancellation. This is the test that would have caught #1 in the first place; it fails against the pre-fix code. Then the small independent correctness fixes: - #4 registry bare-record parse: fall back to decoding the raw payload as PropertyRecord when the {"property": {...}} envelope isn't found. The old code negative-cached bare records as "not found" for 300 s, silently. - #7 per-provider request IDs: build ctxReq and idReq inside callProvider so two colluding providers don't get the same request_id pair for the same auction. - #8 hostile domain: cap site.domain / app.bundle at 253 chars and restrict to `[a-z0-9._-]`. Rejects invalid keys before touching the LRU or the registry. Then the design/product calls user directed on: - #3 fail-closed on identity error: providerResult tracks IdentityAttempted (URL configured AND tokens present). When IdentityAttempted is true but the call returned no response, mergeSegments drops all offers for that provider. A hostile or flaky identity endpoint can no longer convert identity-gated packages into unconditionally-served packages. - #5 cap segments + batch per-bid targeting write. New config knobs MaxSegments (default 128) and MaxSegmentValueLen (default 256) bound both the total segment count and each segment's length, regardless of what a provider returns. Response-hook per-bid targeting now builds one map from segments and writes it via a single sjson.SetBytes per bid at ext.prebid.targeting instead of O(bids × segments) rewrites. Provider names are also validated in Config.validated() to prevent them from colliding with Prebid's reserved targeting prefixes (hb_*). - #9 wire the masking config. coarseGeo now takes cfg and honors PreserveMetro / PreserveZip / PreserveCity / LatLongPrecision (with math.Trunc for lat/lon precision); extractIdentities honors PreserveMobileIds (drops maid-typed tokens when masking enabled and false) and PreserveEids (allowlist over the default set). Deleted the now-empty masking.go — every knob is now wired through adapter.go at input-derivation time. Nits also addressed: - Analytics no longer reports Success when every provider errored; errCount from routerResult drives Status/ResultStatus explicitly. - site.page's query and fragment components are stripped before emission as an artifact ref — gclid / click IDs / occasional email leak into the identity-free context path. - Ordering test reframed: with DecorrelationMaxDelayMs > 0 the second-to-spawn call is deterministically delayed, so HTTP arrival order actually reflects the shuffle instead of scheduler noise. - Panic-recovery test replaced with one that injects a panicking RoundTripper — actually exercises the recover paths in callProvider's inner goroutines. - Nil-guard on payload.BidResponse in the response-hook mutation. - Removed unused asyncRequest.err field. --- modules/adcontextprotocol/tmp/README.md | 19 +- modules/adcontextprotocol/tmp/adapter.go | 100 +++++- modules/adcontextprotocol/tmp/config.go | 37 ++ modules/adcontextprotocol/tmp/hooks.go | 162 ++++++--- modules/adcontextprotocol/tmp/hooks_test.go | 211 ++++++++++++ modules/adcontextprotocol/tmp/masking.go | 61 ---- modules/adcontextprotocol/tmp/module.go | 15 +- .../tmp/property_registry.go | 57 +++- modules/adcontextprotocol/tmp/router.go | 320 +++++++++++------- modules/adcontextprotocol/tmp/router_test.go | 133 ++++++-- 10 files changed, 809 insertions(+), 306 deletions(-) create mode 100644 modules/adcontextprotocol/tmp/hooks_test.go delete mode 100644 modules/adcontextprotocol/tmp/masking.go diff --git a/modules/adcontextprotocol/tmp/README.md b/modules/adcontextprotocol/tmp/README.md index 60050eed7ae..03359750650 100644 --- a/modules/adcontextprotocol/tmp/README.md +++ b/modules/adcontextprotocol/tmp/README.md @@ -51,13 +51,24 @@ hooks: decorrelation_max_delay_ms: 0 targeting_key: adcp add_to_targeting: false + # Caps on the segment set surfaced onto the response ext. Guards + # against a misbehaving or hostile provider bloating the bid + # response. + max_segments: 128 + max_segment_value_len: 256 + # Masking gates optional finer-grained fields into the context + # payload (zip / city / lat-long) and controls which EID sources + # / mobile IDs flow into the identity payload. Defaults are + # strict: only country / region / metro on the context path; + # a small hardcoded EID whitelist on the identity path unless + # `enabled: true` and `preserve_eids` narrows or widens it. masking: enabled: true geo: preserve_metro: true preserve_zip: false preserve_city: false - lat_long_precision: 2 + lat_long_precision: 0 user: preserve_eids: - liveramp.com @@ -70,12 +81,6 @@ hooks: endpoints: /openrtb2/auction: stages: - entrypoint: - groups: - - timeout: 5 - hook_sequence: - - module_code: "adcontextprotocol.tmp" - hook_impl_code: "HandleEntrypointHook" auction_processed: groups: - timeout: 500 diff --git a/modules/adcontextprotocol/tmp/adapter.go b/modules/adcontextprotocol/tmp/adapter.go index aee67770d6d..7606cbfef92 100644 --- a/modules/adcontextprotocol/tmp/adapter.go +++ b/modules/adcontextprotocol/tmp/adapter.go @@ -1,6 +1,8 @@ package tmp import ( + "math" + "net/url" "strings" "github.com/adcontextprotocol/adcp-go/tmproto" @@ -36,9 +38,13 @@ func deriveInputs(cfg *Config, req *openrtb2.BidRequest) tmpInputs { if req.Site != nil { out.Domain = req.Site.Domain if req.Site.Page != "" { + // Strip the query component before emitting as an artifact + // ref: gclid, click IDs and sometimes emails ride the query + // string, and the context path is supposed to be + // identity-free. Fragment is dropped too — same reasoning. out.ArtifactRefs = append(out.ArtifactRefs, tmproto.ArtifactRef{ Type: tmproto.ArtifactRefTypeURL, - Value: req.Site.Page, + Value: stripURLQueryAndFragment(req.Site.Page), }) } } @@ -70,27 +76,58 @@ func deriveInputs(cfg *Config, req *openrtb2.BidRequest) tmpInputs { } if req.Device != nil && req.Device.Geo != nil { - out.Geo = coarseGeo(req.Device.Geo) + out.Geo = coarseGeo(cfg, req.Device.Geo) out.Country = req.Device.Geo.Country } else if req.User != nil && req.User.Geo != nil { - out.Geo = coarseGeo(req.User.Geo) + out.Geo = coarseGeo(cfg, req.User.Geo) out.Country = req.User.Geo.Country } if req.User != nil { - out.Identities = extractIdentities(req.User) + out.Identities = extractIdentities(cfg, req.User) } out.Consent = extractConsent(req) return out } -// coarseGeo drops fields the TMP context schema forbids (postcode, lat/long, -// accuracy) even after masking. Country / region / metro are the wire allowlist. -func coarseGeo(geo *openrtb2.Geo) map[string]any { +// stripURLQueryAndFragment returns the URL with the query and fragment +// components removed, keeping scheme + host + path. If the input is not +// parseable as a URL, it is returned unchanged (the wire schema +// accepts opaque strings on artifact refs). +func stripURLQueryAndFragment(raw string) string { + u, err := url.Parse(raw) + if err != nil { + return raw + } + u.RawQuery = "" + u.Fragment = "" + return u.String() +} + +// coarseGeo emits the geo fields the TMP context payload carries. When +// masking is enabled, per-field flags gate the finer-grained categories +// (city / zip / lat-lon); with masking disabled the default is the same +// strict-mode fields (country / region / metro) that the TMP wire spec +// treats as coarse enough to not identify a user. Operators who +// explicitly opt into zip / city / lat-lon through the masking config +// take responsibility for that being acceptable at their own provider. +func coarseGeo(cfg *Config, geo *openrtb2.Geo) map[string]any { if geo == nil { return nil } + m := cfg.Masking + preserveMetro := true + preserveZip := false + preserveCity := false + latLongPrecision := 0 + if m.Enabled { + preserveMetro = m.Geo.PreserveMetro + preserveZip = m.Geo.PreserveZip + preserveCity = m.Geo.PreserveCity + latLongPrecision = m.Geo.LatLongPrecision + } + out := map[string]any{} if geo.Country != "" { out["country"] = geo.Country @@ -98,20 +135,42 @@ func coarseGeo(geo *openrtb2.Geo) map[string]any { if geo.Region != "" { out["region"] = geo.Region } - if geo.Metro != "" { + if preserveMetro && geo.Metro != "" { out["metro"] = geo.Metro } + if preserveZip && geo.ZIP != "" { + out["zip"] = geo.ZIP + } + if preserveCity && geo.City != "" { + out["city"] = geo.City + } + if latLongPrecision > 0 && geo.Lat != nil && geo.Lon != nil { + out["lat"] = truncateCoord(*geo.Lat, latLongPrecision) + out["lon"] = truncateCoord(*geo.Lon, latLongPrecision) + } if len(out) == 0 { return nil } return out } +// truncateCoord truncates a coordinate to n decimal places using +// math.Trunc so negative coordinates truncate toward zero (matching +// what most operators expect for a "reduce precision" knob). +func truncateCoord(v float64, precision int) float64 { + mult := math.Pow(10, float64(precision)) + return math.Trunc(v*mult) / mult +} + // extractIdentities maps openrtb2 user.eids → tmproto.IdentityToken, honoring // the TMP cap of three tokens. Priority order: rampid, uid2, id5, then whatever // remains. Publishers that need a different priority should tell us — right now // this is the most common set. -func extractIdentities(user *openrtb2.User) []tmproto.IdentityToken { +// +// When Masking is enabled and PreserveMobileIds is false, maid-typed +// tokens (mobile advertising IDs) are dropped from the identity set so +// they never reach a TMP provider. +func extractIdentities(cfg *Config, user *openrtb2.User) []tmproto.IdentityToken { if user == nil { return nil } @@ -121,6 +180,21 @@ func extractIdentities(user *openrtb2.User) []tmproto.IdentityToken { "id5-sync.com": 2, "euid.eu": 3, "adserver.org": 4, + "adid.google": 5, + "idfa.apple": 5, + } + dropMaid := cfg.Masking.Enabled && !cfg.Masking.Device.PreserveMobileIds + // When PreserveEids is set (masking enabled + operator populated it, + // or the default filled in by validated()) it is authoritative: only + // EID sources on the allowlist survive. Publishers who want the + // default hardcoded whitelist just leave masking off; publishers who + // want a narrower allowlist enable masking + populate the field. + var eidAllowlist map[string]bool + if cfg.Masking.Enabled && len(cfg.Masking.User.PreserveEids) > 0 { + eidAllowlist = make(map[string]bool, len(cfg.Masking.User.PreserveEids)) + for _, s := range cfg.Masking.User.PreserveEids { + eidAllowlist[strings.ToLower(s)] = true + } } type scored struct { tok tmproto.IdentityToken @@ -132,10 +206,16 @@ func extractIdentities(user *openrtb2.User) []tmproto.IdentityToken { if len(eid.UIDs) == 0 || eid.UIDs[0].ID == "" { continue } + if eidAllowlist != nil && !eidAllowlist[strings.ToLower(eid.Source)] { + continue + } uidType := mapEIDToUIDType(eid.Source) if uidType == "" { continue } + if dropMaid && uidType == tmproto.UIDTypeMAID { + continue + } // Every source that survives mapEIDToUIDType has a priority entry; // the map fallback is unreachable. candidates = append(candidates, scored{ @@ -180,6 +260,8 @@ func mapEIDToUIDType(source string) tmproto.UIDType { return tmproto.UIDTypeEUID case "adserver.org": return tmproto.UIDTypePairID + case "adid.google", "idfa.apple": + return tmproto.UIDTypeMAID } return "" } diff --git a/modules/adcontextprotocol/tmp/config.go b/modules/adcontextprotocol/tmp/config.go index 84209f839ff..c859992a706 100644 --- a/modules/adcontextprotocol/tmp/config.go +++ b/modules/adcontextprotocol/tmp/config.go @@ -4,6 +4,7 @@ import ( "crypto/ed25519" "errors" "fmt" + "regexp" "github.com/adcontextprotocol/adcp-go/tmproto" ) @@ -58,6 +59,17 @@ type Config struct { // AddToTargeting mirrors the response signals into prebid.targeting so // downstream ad servers (e.g. GAM) can consume them. AddToTargeting bool `json:"add_to_targeting"` + + // MaxSegments caps the total number of segments emitted onto the + // response ext, regardless of how many providers respond or how many + // offers/signals they include. Default 128. A hostile-or-buggy + // provider cannot bloat the bid response past this bound. + MaxSegments int `json:"max_segments"` + + // MaxSegmentValueLen bounds each emitted segment string (name + + // separator + value). Default 256. Excess is truncated. A cap of 0 + // disables truncation. + MaxSegmentValueLen int `json:"max_segment_value_len"` } // SigningConfig carries the private-key material used to sign outbound TMP @@ -127,6 +139,14 @@ type DeviceMaskingConfig struct { PreserveMobileIds bool `json:"preserve_mobile_ids"` } +// providerNameRE constrains provider names so an operator cannot +// accidentally name a provider "hb" (or similar) and have its emitted +// segment keys collide with Prebid's own reserved targeting keys (e.g. +// hb_pb, hb_adid). The prefix in emitted segments is provider name + +// underscore; restricting to a lower-case identifier keeps the prefix +// unambiguous. +var providerNameRE = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,31}$`) + // validated returns a Config with defaults filled in, along with the parsed // Ed25519 private key. Invalid configuration is rejected here rather than at // call sites. @@ -147,11 +167,19 @@ func (c *Config) validated() (ed25519.PrivateKey, error) { if len(c.Providers) == 0 { return nil, errors.New("at least one provider is required") } + seenNames := make(map[string]bool, len(c.Providers)) for i := range c.Providers { p := &c.Providers[i] if p.Name == "" { return nil, fmt.Errorf("providers[%d].name is required", i) } + if !providerNameRE.MatchString(p.Name) { + return nil, fmt.Errorf("providers[%d].name %q must match %s (lowercase letters, digits, underscore, hyphen; up to 32 chars)", i, p.Name, providerNameRE) + } + if seenNames[p.Name] { + return nil, fmt.Errorf("providers[%d].name %q is duplicated", i, p.Name) + } + seenNames[p.Name] = true if p.IdentityURL == "" && p.ContextURL == "" { return nil, fmt.Errorf("providers[%d] (%s): at least one of identity_url or context_url is required", i, p.Name) } @@ -181,6 +209,15 @@ func (c *Config) validated() (ed25519.PrivateKey, error) { if c.TargetingKey == "" { c.TargetingKey = "adcp" } + if c.MaxSegments <= 0 { + c.MaxSegments = 128 + } + if c.MaxSegmentValueLen < 0 { + return nil, errors.New("max_segment_value_len cannot be negative") + } + if c.MaxSegmentValueLen == 0 { + c.MaxSegmentValueLen = 256 + } if c.Masking.Enabled { if c.Masking.Geo.LatLongPrecision > 4 { return nil, errors.New("masking.geo.lat_long_precision cannot exceed 4") diff --git a/modules/adcontextprotocol/tmp/hooks.go b/modules/adcontextprotocol/tmp/hooks.go index b7db3256123..7d9d38e318b 100644 --- a/modules/adcontextprotocol/tmp/hooks.go +++ b/modules/adcontextprotocol/tmp/hooks.go @@ -10,43 +10,52 @@ import ( "github.com/tidwall/sjson" ) -// HandleEntrypointHook allocates the per-auction async request holder along -// with a cancelable context that survives across hook stages. The response -// hook cancels it on the way out so an in-flight fan-out does not outlive the -// auction. -func (m *Module) HandleEntrypointHook( - ctx context.Context, - _ hookstage.ModuleInvocationContext, - _ hookstage.EntrypointPayload, -) (hookstage.HookResult[hookstage.EntrypointPayload], error) { - fanoutCtx, cancel := context.WithCancel(ctx) - moduleContext := hookstage.NewModuleContext() - moduleContext.Set(asyncKey, &asyncRequest{ - done: make(chan struct{}), - ctx: fanoutCtx, - cancel: cancel, - }) - return hookstage.HookResult[hookstage.EntrypointPayload]{ModuleContext: moduleContext}, nil -} - -// HandleProcessedAuctionHook kicks off the TMP fan-out in the background. The -// auction continues immediately; results are collected in the response hook. +// HandleProcessedAuctionHook snapshots the relevant fields from the +// live BidRequest synchronously, allocates the async result holder, +// then kicks off the TMP fan-out in a background goroutine. The +// goroutine never touches the BidRequest again — deriveInputs runs on +// the caller's stack so there is no data race with concurrent hook +// stages / privacy scrubbing / other modules that continue to mutate +// the request wrapper after this hook returns. +// +// The fan-out context is rooted in context.Background(), NOT the hook +// caller's context: the framework cancels every hook's own ctx the +// moment the hook returns (hooks/hookexecution/execution.go), so a +// derived ctx would be Done before the fan-out started. The response +// hook cancels it via defer async.cancel() when the auction is ready +// to serve. func (m *Module) HandleProcessedAuctionHook( _ context.Context, - miCtx hookstage.ModuleInvocationContext, + _ hookstage.ModuleInvocationContext, payload hookstage.ProcessedAuctionRequestPayload, ) (hookstage.HookResult[hookstage.ProcessedAuctionRequestPayload], error) { var res hookstage.HookResult[hookstage.ProcessedAuctionRequestPayload] - - async, ok := m.loadAsync(miCtx) - if !ok { + if payload.Request == nil || payload.Request.BidRequest == nil { return res, nil } - if payload.Request == nil || payload.Request.BidRequest == nil { - close(async.done) + + // Snapshot everything the fan-out needs off the live request BEFORE + // spawning the goroutine. deriveInputs is pure CPU with no I/O; the + // snapshot is a value (map[string]any is copied by re-emission at + // coarseGeo, identities is a fresh []IdentityToken slice, etc.) that + // the goroutine can hold independently while the auction rebuilds + // req.Ext / user.ext elsewhere. + inputs := deriveInputs(&m.cfg, payload.Request.BidRequest) + if inputs.PlacementID == "" || (inputs.Domain == "" && inputs.Bundle == "") { + // Nothing to fan out — skip both the holder allocation and the + // goroutine so the response hook cleanly returns without + // waiting. return res, nil } - bidRequest := payload.Request.BidRequest + + fanoutCtx, cancel := context.WithCancel(context.Background()) + async := &asyncRequest{ + done: make(chan struct{}), + ctx: fanoutCtx, + cancel: cancel, + } + moduleCtx := hookstage.NewModuleContext() + moduleCtx.Set(asyncKey, async) go func() { defer func() { @@ -55,8 +64,10 @@ func (m *Module) HandleProcessedAuctionHook( } close(async.done) }() - async.result = m.fanOut(async.ctx, bidRequest) + async.result = m.fanOut(fanoutCtx, inputs) }() + + res.ModuleContext = moduleCtx return res, nil } @@ -80,15 +91,27 @@ func (m *Module) HandleAuctionResponseHook( case <-ctx.Done(): return res, nil } - if async.result == nil || len(async.result.Segments) == 0 { + + var ( + segments []string + errCount int + ) + if async.result != nil { + segments = async.result.Segments + errCount = async.result.ErrCount + } + if len(segments) == 0 { + res.AnalyticsTags = analyticsForResult(0, errCount) return res, nil } - segments := async.result.Segments targetingKey := m.cfg.TargetingKey addToTargeting := m.cfg.AddToTargeting res.ChangeSet.AddMutation( func(payload hookstage.AuctionResponsePayload) (hookstage.AuctionResponsePayload, error) { + if payload.BidResponse == nil { + return payload, nil + } ext := payload.BidResponse.Ext newExt, err := sjson.SetBytes(ext, targetingKey+".segments", segments) if err != nil { @@ -98,27 +121,24 @@ func (m *Module) HandleAuctionResponseHook( } payload.BidResponse.Ext = ext - // Per-bid targeting is where GAM et al actually read keys, so - // mirror the response-level segments onto each bid's ext when - // enabled. - if addToTargeting { - for seatBid := range iterutil.SlicePointerValues(payload.BidResponse.SeatBid) { - for bid := range iterutil.SlicePointerValues(seatBid.Bid) { - bidExt := bid.Ext - for _, s := range segments { - kv := splitKV(s) - if kv == nil { - continue - } - updated, err := sjson.SetBytes(bidExt, "prebid.targeting."+kv[0], kv[1]) - if err != nil { - logger.Errorf("adcontextprotocol.tmp: bid targeting set: %v", err) - continue - } - bidExt = updated - } - bid.Ext = bidExt + if !addToTargeting { + return payload, nil + } + // Batch the per-bid targeting update: build the (key,value) + // pairs once outside the seatbid loop so each bid gets O(1) + // sjson rewrites instead of O(segments). + targetingMap := targetingMapFromSegments(segments) + if len(targetingMap) == 0 { + return payload, nil + } + for seatBid := range iterutil.SlicePointerValues(payload.BidResponse.SeatBid) { + for bid := range iterutil.SlicePointerValues(seatBid.Bid) { + updated, err := sjson.SetBytes(bid.Ext, "prebid.targeting", targetingMap) + if err != nil { + logger.Errorf("adcontextprotocol.tmp: bid targeting set: %v", err) + continue } + bid.Ext = updated } } return payload, nil @@ -127,17 +147,47 @@ func (m *Module) HandleAuctionResponseHook( "ext", ) - res.AnalyticsTags = hookanalytics.Analytics{ + res.AnalyticsTags = analyticsForResult(len(segments), errCount) + return res, nil +} + +// analyticsForResult surfaces both success and failure signal so the +// module cannot silently report Success when every provider errored. +func analyticsForResult(segments, errCount int) hookanalytics.Analytics { + status := hookanalytics.ActivityStatusSuccess + resultStatus := hookanalytics.ResultStatusAllow + if segments == 0 && errCount > 0 { + status = hookanalytics.ActivityStatusError + resultStatus = hookanalytics.ResultStatusError + } + return hookanalytics.Analytics{ Activities: []hookanalytics.Activity{{ Name: "adcontextprotocol.tmp.fanout", - Status: hookanalytics.ActivityStatusSuccess, + Status: status, Results: []hookanalytics.Result{{ - Status: hookanalytics.ResultStatusAllow, - Values: map[string]any{"segments": len(segments)}, + Status: resultStatus, + Values: map[string]any{ + "segments": segments, + "err_count": errCount, + }, }}, }}, } - return res, nil +} + +// targetingMapFromSegments converts the "key=value" segment slice into +// a flat map suitable for a single sjson.SetBytes into +// ext.prebid.targeting. Duplicate keys keep the last-wins value. +func targetingMapFromSegments(segments []string) map[string]string { + out := make(map[string]string, len(segments)) + for _, s := range segments { + kv := splitKV(s) + if kv == nil { + continue + } + out[kv[0]] = kv[1] + } + return out } func (m *Module) loadAsync(miCtx hookstage.ModuleInvocationContext) (*asyncRequest, bool) { diff --git a/modules/adcontextprotocol/tmp/hooks_test.go b/modules/adcontextprotocol/tmp/hooks_test.go new file mode 100644 index 00000000000..7d28d249ed0 --- /dev/null +++ b/modules/adcontextprotocol/tmp/hooks_test.go @@ -0,0 +1,211 @@ +package tmp + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/adcontextprotocol/adcp-go/tmproto" + "github.com/prebid/openrtb/v20/openrtb2" + "github.com/prebid/prebid-server/v4/hooks/hookstage" + "github.com/prebid/prebid-server/v4/openrtb_ext" +) + +// This file's tests exercise the module through the hookstage API +// exactly as the framework does, so regressions like "the fan-out +// context is Done before the goroutine runs" cannot slip through by +// only testing fanOut directly (which is what the initial PR did). +// +// The critical assertion: after HandleProcessedAuctionHook returns and +// HandleAuctionResponseHook completes, the bid response ext MUST carry +// the merged segments. If the fan-out ctx was derived from the +// entrypoint hook's ctx (framework cancels it on hook return), no +// segments land — this test catches that class of bug. + +func newHooksFixtureModule(t *testing.T) (*Module, func()) { + t.Helper() + registry := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "property": map[string]any{ + "property_rid": "01916f3a-1234-7000-8000-000000000001", + "property_id": "fixture", + "property_type": "website", + "domain": r.URL.Query().Get("domain"), + }, + }) + })) + provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/context": + _ = json.NewEncoder(w).Encode(tmproto.ContextMatchResponse{ + Type: "context_match_response", + RequestID: "req", + Offers: []tmproto.Offer{{PackageID: "pkg-a"}}, + }) + case "/identity": + _ = json.NewEncoder(w).Encode(tmproto.IdentityMatchResponse{ + Type: "identity_match_response", + RequestID: "req", + EligiblePackageIDs: []string{"pkg-a"}, + }) + default: + http.NotFound(w, r) + } + })) + + cfg := Config{ + SellerAgentURL: "https://seller.example.com", + Signing: SigningConfig{ + KeyID: "kid-1", + PrivateKeyPEM: genTestKey(t), + }, + PropertyRegistry: PropertyRegistryConfig{Endpoint: registry.URL}, + Providers: []ProviderConfig{{ + Name: "prov", + IdentityURL: provider.URL + "/identity", + ContextURL: provider.URL + "/context", + }}, + } + priv, err := cfg.validated() + if err != nil { + t.Fatalf("validated: %v", err) + } + signer, err := tmproto.NewSigner(cfg.Signing.KeyID, priv) + if err != nil { + t.Fatalf("signer: %v", err) + } + m := &Module{ + cfg: cfg, + signer: signer, + http: http.DefaultClient, + registry: newPropertyResolver(cfg.PropertyRegistry, nil), + } + return m, func() { + registry.Close() + provider.Close() + } +} + +func TestHooks_EndToEndDeliversSegments(t *testing.T) { + m, cleanup := newHooksFixtureModule(t) + defer cleanup() + + // Simulate the framework's per-hook cancellation: pass each hook a + // context that gets cancelled the moment the hook returns. If the + // module were rooting its fan-out in this context (as it did before + // this fix), the goroutine would fire with an already-cancelled ctx + // and the segments would never land. + runHook := func(fn func(ctx context.Context)) { + hookCtx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + fn(hookCtx) + } + + bidReq := &openrtb2.BidRequest{ + ID: "auction-1", + Site: &openrtb2.Site{Domain: "publisher.example"}, + Imp: []openrtb2.Imp{{ID: "imp-1", TagID: "slot-1"}}, + User: &openrtb2.User{ + EIDs: []openrtb2.EID{{Source: "liveramp.com", UIDs: []openrtb2.UID{{ID: "ramp-x"}}}}, + }, + } + wrapper := &openrtb_ext.RequestWrapper{BidRequest: bidReq} + + // Stage 1: HandleProcessedAuctionHook — snapshots inputs, spawns + // fan-out on a Background-rooted ctx, returns the module context. + var processedRes hookstage.HookResult[hookstage.ProcessedAuctionRequestPayload] + runHook(func(ctx context.Context) { + var err error + processedRes, err = m.HandleProcessedAuctionHook(ctx, hookstage.ModuleInvocationContext{}, hookstage.ProcessedAuctionRequestPayload{Request: wrapper}) + if err != nil { + t.Fatalf("processed hook: %v", err) + } + }) + if processedRes.ModuleContext == nil { + t.Fatal("expected processed hook to set ModuleContext with the async holder") + } + // The hook ctx passed above is now cancelled. If the module rooted + // its fan-out ctx in it, the fan-out is dead. The response hook is + // where we discover that. + + // Stage 2: HandleAuctionResponseHook — waits on the fan-out and + // mutates the response ext. + bidResp := &openrtb2.BidResponse{ID: "auction-1", SeatBid: []openrtb2.SeatBid{{Bid: []openrtb2.Bid{{ID: "bid-1"}}}}} + var responseRes hookstage.HookResult[hookstage.AuctionResponsePayload] + runHook(func(ctx context.Context) { + miCtx := hookstage.ModuleInvocationContext{ModuleContext: processedRes.ModuleContext} + var err error + responseRes, err = m.HandleAuctionResponseHook(ctx, miCtx, hookstage.AuctionResponsePayload{BidResponse: bidResp}) + if err != nil { + t.Fatalf("response hook: %v", err) + } + }) + + // Apply the mutation and assert the segment landed on the response ext. + mutations := responseRes.ChangeSet.Mutations() + if len(mutations) == 0 { + t.Fatal("no mutation emitted — fan-out produced no segments (regression #1)") + } + payload := hookstage.AuctionResponsePayload{BidResponse: bidResp} + for _, mut := range mutations { + next, err := mut.Apply(payload) + if err != nil { + t.Fatalf("mutation apply: %v", err) + } + payload = next + } + if len(payload.BidResponse.Ext) == 0 { + t.Fatal("response ext still empty after applying mutation") + } + // The exact JSON path is `adcp.segments`; a substring search is + // enough — the strict assertion is "some segment survived the + // hook plumbing", which is the invariant that broke. + if !bytesContains(payload.BidResponse.Ext, "prov_package=pkg-a") { + t.Errorf("expected prov_package=pkg-a in response ext; got %s", string(payload.BidResponse.Ext)) + } +} + +func TestHooks_NoOpWhenPlacementMissing(t *testing.T) { + m, cleanup := newHooksFixtureModule(t) + defer cleanup() + + bidReq := &openrtb2.BidRequest{ + Site: &openrtb2.Site{Domain: "publisher.example"}, + Imp: []openrtb2.Imp{{ID: "imp-1"}}, // no TagID + } + wrapper := &openrtb_ext.RequestWrapper{BidRequest: bidReq} + res, err := m.HandleProcessedAuctionHook(context.Background(), hookstage.ModuleInvocationContext{}, hookstage.ProcessedAuctionRequestPayload{Request: wrapper}) + if err != nil { + t.Fatalf("processed hook: %v", err) + } + if res.ModuleContext != nil { + t.Error("expected no ModuleContext when there is nothing to fan out") + } + + // Response hook should short-circuit cleanly when there's no holder. + rres, err := m.HandleAuctionResponseHook(context.Background(), hookstage.ModuleInvocationContext{}, hookstage.AuctionResponsePayload{}) + if err != nil { + t.Fatalf("response hook: %v", err) + } + if len(rres.ChangeSet.Mutations()) != 0 { + t.Errorf("expected no mutation on empty ModuleContext; got %d", len(rres.ChangeSet.Mutations())) + } +} + +// bytesContains is a helper because JSON path assertions on []byte are +// noisy — we just want to see the segment string appear somewhere. +func bytesContains(hay []byte, needle string) bool { + if len(needle) == 0 { + return true + } + nb := []byte(needle) + for i := 0; i+len(nb) <= len(hay); i++ { + if string(hay[i:i+len(nb)]) == needle { + return true + } + } + return false +} diff --git a/modules/adcontextprotocol/tmp/masking.go b/modules/adcontextprotocol/tmp/masking.go deleted file mode 100644 index d246b72c1d7..00000000000 --- a/modules/adcontextprotocol/tmp/masking.go +++ /dev/null @@ -1,61 +0,0 @@ -package tmp - -import ( - "github.com/adcontextprotocol/adcp-go/tmproto" -) - -// maskGeoMap coarsens a TMP context geo map according to the module's masking -// configuration. The TMP context schema already forbids postcode and lat/long, -// so this operates on the enum-safe fields (metro, region, country, city). -// Returns nil if masking removed everything. -func (m *Module) maskGeoMap(geo map[string]any) map[string]any { - if geo == nil { - return nil - } - out := make(map[string]any, len(geo)) - for k, v := range geo { - switch k { - case "country", "region": - out[k] = v - case "metro": - if m.cfg.Masking.Geo.PreserveMetro { - out[k] = v - } - case "city": - if m.cfg.Masking.Geo.PreserveCity { - out[k] = v - } - case "zip", "zipcode": - if m.cfg.Masking.Geo.PreserveZip { - out[k] = v - } - } - } - if len(out) == 0 { - return nil - } - return out -} - -// filterIdentities drops any identity token whose source is not on the -// preserve_eids allowlist. Called for defense-in-depth: mapEIDToUIDType -// already restricts to sources the TMP wire recognizes, but operators may want -// a tighter allowlist per jurisdiction. -func (m *Module) filterIdentities(tokens []tmproto.IdentityToken) []tmproto.IdentityToken { - if len(m.cfg.Masking.User.PreserveEids) == 0 { - return tokens - } - allowed := make(map[tmproto.UIDType]bool, len(m.cfg.Masking.User.PreserveEids)) - for _, src := range m.cfg.Masking.User.PreserveEids { - if t := mapEIDToUIDType(src); t != "" { - allowed[t] = true - } - } - out := make([]tmproto.IdentityToken, 0, len(tokens)) - for _, t := range tokens { - if allowed[t.UIDType] { - out = append(out, t) - } - } - return out -} diff --git a/modules/adcontextprotocol/tmp/module.go b/modules/adcontextprotocol/tmp/module.go index e42b838eea0..34159cad0fd 100644 --- a/modules/adcontextprotocol/tmp/module.go +++ b/modules/adcontextprotocol/tmp/module.go @@ -65,20 +65,23 @@ type Module struct { const asyncKey = "adcontextprotocol.tmp.asyncRequest" // Hook interface assertions — the compiler catches signature drift here. +// No Entrypoint hook: the module allocates its per-auction async holder +// in HandleProcessedAuctionHook so the fan-out can inherit a +// Background-rooted context. Rooting in an entrypoint hook's ctx would +// leave the fan-out with an already-cancelled parent (the framework +// cancels each hook's own ctx the moment the hook returns). var ( - _ hookstage.Entrypoint = (*Module)(nil) _ hookstage.ProcessedAuctionRequest = (*Module)(nil) _ hookstage.AuctionResponse = (*Module)(nil) ) -// asyncRequest carries a single auction's in-flight TMP fan-out from the -// entrypoint hook through to the response hook. ctx / cancel are owned here -// (not the hook's own ctx) so the response hook can guarantee no orphan -// goroutine survives the auction. +// asyncRequest carries a single auction's in-flight TMP fan-out from +// HandleProcessedAuctionHook to HandleAuctionResponseHook. ctx / cancel +// are owned here (not any hook's ctx) so the response hook can +// guarantee no orphan goroutine survives the auction. type asyncRequest struct { done chan struct{} ctx context.Context cancel context.CancelFunc result *routerResult - err error } diff --git a/modules/adcontextprotocol/tmp/property_registry.go b/modules/adcontextprotocol/tmp/property_registry.go index 2cb8d3ff1e7..7e408a2206c 100644 --- a/modules/adcontextprotocol/tmp/property_registry.go +++ b/modules/adcontextprotocol/tmp/property_registry.go @@ -64,6 +64,14 @@ func newPropertyResolver(cfg PropertyRegistryConfig, transport http.RoundTripper } } +// maxDomainKeyLen caps the length of a cache key / registry query +// parameter derived from the bid request's site.domain or app.bundle. +// 253 is the RFC 1035 max length of a fully-qualified domain name; app +// bundle IDs also fit comfortably below it. Longer inputs are rejected +// so a hostile bid request cannot inflate the LRU or amplify to the +// registry with garbage keys. +const maxDomainKeyLen = 253 + // Resolve looks up a property by canonical domain (site) or bundle (app). // Returns (record, true, nil) on hit, (nil, false, nil) on cached negative, // (nil, false, err) on registry error. @@ -72,6 +80,12 @@ func (p *propertyResolver) Resolve(ctx context.Context, domain string) (*Propert if key == "" { return nil, false, errors.New("empty domain") } + if len(key) > maxDomainKeyLen { + return nil, false, fmt.Errorf("domain length %d exceeds cap %d", len(key), maxDomainKeyLen) + } + if !isValidDomainOrBundle(key) { + return nil, false, errors.New("domain contains invalid characters") + } if rec, ok, fresh := p.cacheGet(key); fresh { return rec, ok, nil @@ -133,20 +147,26 @@ func (p *propertyResolver) fetch(ctx context.Context, domain string) (*PropertyR if err != nil { return nil, fmt.Errorf("registry read: %w", err) } + // Registry implementations vary. Try the wrapped + // {"property": {...}} envelope first; if that yields no + // property_rid, try decoding the payload as a bare + // PropertyRecord — some deployments (including + // agenticadvertising.org's /api/properties/resolve) return the + // record directly, not nested. var body registryResponse if err := jsonutil.Unmarshal(raw, &body); err != nil { return nil, fmt.Errorf("registry decode: %w", err) } - // Some registry implementations return the record directly, others wrap - // it in {"property": {...}}. Handle both. - if body.Property != nil { - if body.Property.PropertyRID != "" { - return body.Property, nil - } + if body.Property != nil && body.Property.PropertyRID != "" { + return body.Property, nil } if body.Found != nil && !*body.Found { return nil, nil } + var bare PropertyRecord + if err := jsonutil.Unmarshal(raw, &bare); err == nil && bare.PropertyRID != "" { + return &bare, nil + } return nil, nil case http.StatusNotFound: return nil, nil @@ -236,3 +256,28 @@ func (s *singleflight) do(key string, fn func() (*PropertyRecord, error)) (*Prop s.mu.Unlock() return c.rec, c.err } + +// isValidDomainOrBundle returns true when the input contains only +// characters plausible in a DNS name or an app-store bundle identifier. +// Rejects whitespace, control chars, URL delimiters, and quoting — +// anything a hostile bid request might inject to smuggle payloads +// through the registry lookup or bloat the LRU key space with garbage. +// Intentionally permissive on shape: no length-per-label check, no dot +// requirement — DNS labels can be single-character and bundle ids like +// `com.example.app` and `com.example-app.v2` are both valid. +func isValidDomainOrBundle(s string) bool { + if s == "" { + return false + } + for i := 0; i < len(s); i++ { + c := s[i] + switch { + case c >= 'a' && c <= 'z': + case c >= '0' && c <= '9': + case c == '.' || c == '-' || c == '_': + default: + return false + } + } + return true +} diff --git a/modules/adcontextprotocol/tmp/router.go b/modules/adcontextprotocol/tmp/router.go index 0b911b2b937..d7893aa7fb3 100644 --- a/modules/adcontextprotocol/tmp/router.go +++ b/modules/adcontextprotocol/tmp/router.go @@ -8,18 +8,23 @@ import ( "time" "github.com/adcontextprotocol/adcp-go/tmproto" - "github.com/prebid/openrtb/v20/openrtb2" "github.com/prebid/prebid-server/v4/logger" ) -// providerResult holds one provider's contribution after both endpoints have -// been called (whichever were configured). Nil fields mean "not configured" or -// "call failed" — callers should treat both the same way when merging. +// providerResult holds one provider's contribution after both endpoints +// have been called (whichever were configured). type providerResult struct { - Name string - Context *tmproto.ContextMatchResponse + Name string + // Context is set when the context call succeeded, nil otherwise. + Context *tmproto.ContextMatchResponse + // Identity is set when the identity call succeeded, nil otherwise. Identity *tmproto.IdentityMatchResponse - Errs []error + // IdentityAttempted is true when the module actually issued an + // identity call for this provider (URL configured AND tokens + // present). Lets the merge distinguish "identity errored" (fail + // closed) from "identity not applicable" (offers pass). + IdentityAttempted bool + Errs []error } // routerResult is the joined view across all providers. @@ -27,28 +32,26 @@ type routerResult struct { Providers []providerResult // Segments are the flat targeting strings the response hook writes into // bid ext. Each string is "key=value" so consumers can split on the - // separator downstream. + // separator downstream. Post-cap. Segments []string + // ErrCount is the number of providers that produced at least one + // error. Surfaced via analytics so a silent-failure regression is + // visible in dashboards. + ErrCount int } -// fanOut executes the module's TMP flow for a single auction: adapt the bid -// request, resolve the property, then call every configured provider's -// context and identity endpoints in parallel. Returns quickly if the property -// cannot be resolved — the auction proceeds without TMP signals. -func (m *Module) fanOut(ctx context.Context, req *openrtb2.BidRequest) *routerResult { - inputs := deriveInputs(&m.cfg, req) - +// fanOut executes the module's TMP flow for a single auction against a +// pre-derived tmpInputs snapshot. Caller must have already run +// deriveInputs synchronously — this function does not touch the +// BidRequest, so it is safe to run in a background goroutine while the +// auction continues to mutate the request wrapper. +func (m *Module) fanOut(ctx context.Context, inputs tmpInputs) *routerResult { // Domain / bundle → property_rid. lookupKey := inputs.Domain if lookupKey == "" { lookupKey = inputs.Bundle } - if lookupKey == "" { - return &routerResult{} - } - // PlacementID is a required TMP context field. Firing without one produces - // a payload every well-behaved provider will 400, so short-circuit here. - if inputs.PlacementID == "" { + if lookupKey == "" || inputs.PlacementID == "" { return &routerResult{} } @@ -65,19 +68,67 @@ func (m *Module) fanOut(ctx context.Context, req *openrtb2.BidRequest) *routerRe propertyType = inputs.PropertyType } - // Apply masking before we let the ContextMatchRequest leave the process. - if m.cfg.Masking.Enabled { - maskedGeo := m.maskGeoMap(inputs.Geo) - if maskedGeo != nil { - inputs.Geo = maskedGeo + // Masking is applied at input derivation time (deriveInputs already + // respected the masking config) so nothing here needs to re-filter. + + results := make([]providerResult, len(m.cfg.Providers)) + var wg sync.WaitGroup + + for i, p := range m.cfg.Providers { + wg.Add(1) + go func(i int, p ProviderConfig) { + defer wg.Done() + defer func() { + if r := recover(); r != nil { + logger.Errorf("adcontextprotocol.tmp: panic in provider %s fan-out: %v", p.Name, r) + results[i] = providerResult{Name: p.Name, Errs: []error{fmt.Errorf("panic: %v", r)}} + } + }() + results[i] = m.callProvider(ctx, p, inputs, prop, propertyType) + }(i, p) + } + wg.Wait() + + errCount := 0 + for _, r := range results { + if len(r.Errs) > 0 { + errCount++ } - inputs.Identities = m.filterIdentities(inputs.Identities) } + return &routerResult{ + Providers: results, + Segments: m.mergeSegments(results), + ErrCount: errCount, + } +} + +// callProvider builds fresh per-provider request objects (so request_ids +// do not correlate across providers) and issues the configured calls. +func (m *Module) callProvider( + ctx context.Context, + p ProviderConfig, + inputs tmpInputs, + prop *PropertyRecord, + propertyType tmproto.PropertyType, +) providerResult { + res := providerResult{Name: p.Name} + + timeout := time.Duration(p.TimeoutMs) * time.Millisecond + if timeout <= 0 { + timeout = time.Duration(m.cfg.TimeoutMs) * time.Millisecond + } + pCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + // Per-provider request IDs — two colluding providers should not be + // able to join on identical ids for the same auction. TMP §514/555 + // requires context and identity ids not correlate; this goes further + // and gives each provider its own pair. ctxRequestID, err := newRequestID() if err != nil { - logger.Errorf("adcontextprotocol.tmp: request id generation failed: %v", err) - return &routerResult{} + res.Errs = append(res.Errs, fmt.Errorf("request id: %w", err)) + return res } ctxReq := &tmproto.ContextMatchRequest{ Type: "context_match_request", @@ -91,15 +142,12 @@ func (m *Module) fanOut(ctx context.Context, req *openrtb2.BidRequest) *routerRe ArtifactRefs: inputs.ArtifactRefs, } - // Identity request stays absent when the auction has no usable tokens. - // A separate request_id is generated to preserve the TMP privacy - // invariant that context and identity ids MUST NOT correlate. var idReq *tmproto.IdentityMatchRequest - if len(inputs.Identities) > 0 { + if p.IdentityURL != "" && len(inputs.Identities) > 0 { idRequestID, err := newRequestID() if err != nil { - logger.Errorf("adcontextprotocol.tmp: request id generation failed: %v", err) - return &routerResult{} + res.Errs = append(res.Errs, fmt.Errorf("request id: %w", err)) + return res } idReq = &tmproto.IdentityMatchRequest{ Type: "identity_match_request", @@ -109,118 +157,102 @@ func (m *Module) fanOut(ctx context.Context, req *openrtb2.BidRequest) *routerRe Consent: inputs.Consent, Country: inputs.Country, } + res.IdentityAttempted = true } - results := make([]providerResult, len(m.cfg.Providers)) - var wg sync.WaitGroup + // Context and identity fire in parallel per provider so a slow + // endpoint on one side does not starve the other. Order is + // randomized per request and the second call is optionally jittered + // so a passive observer cannot rely on stable timing to pair the two. + var innerWG sync.WaitGroup + var mu sync.Mutex - for i, p := range m.cfg.Providers { - wg.Add(1) - go func(i int, p ProviderConfig) { - defer wg.Done() + var calls []func() + if p.ContextURL != "" { + calls = append(calls, func() { defer func() { if r := recover(); r != nil { - logger.Errorf("adcontextprotocol.tmp: panic in provider %s fan-out: %v", p.Name, r) - results[i] = providerResult{Name: p.Name, Errs: []error{fmt.Errorf("panic: %v", r)}} + mu.Lock() + res.Errs = append(res.Errs, fmt.Errorf("panic in context call: %v", r)) + mu.Unlock() + logger.Errorf("adcontextprotocol.tmp: panic in context call to %s: %v", p.Name, r) } }() - res := providerResult{Name: p.Name} - - // Per-provider deadline; falls back to the module-level timeout. - timeout := time.Duration(p.TimeoutMs) * time.Millisecond - if timeout <= 0 { - timeout = time.Duration(m.cfg.TimeoutMs) * time.Millisecond - } - pCtx, cancel := context.WithTimeout(ctx, timeout) - defer cancel() - - // Context and identity fire in parallel per provider so a slow - // endpoint on one side does not starve the other. Order is - // randomized every request and the second call is optionally - // jittered so a passive observer cannot rely on stable timing to - // pair the two. - var innerWG sync.WaitGroup - var mu sync.Mutex - - var calls []func() - if p.ContextURL != "" { - calls = append(calls, func() { - defer func() { - if r := recover(); r != nil { - mu.Lock() - res.Errs = append(res.Errs, fmt.Errorf("panic in context call: %v", r)) - mu.Unlock() - logger.Errorf("adcontextprotocol.tmp: panic in context call to %s: %v", p.Name, r) - } - }() - resp, err := m.callContext(pCtx, p, ctxReq) - mu.Lock() - defer mu.Unlock() - if err != nil { - res.Errs = append(res.Errs, err) - } else { - res.Context = resp - } - }) + resp, err := m.callContext(pCtx, p, ctxReq) + mu.Lock() + defer mu.Unlock() + if err != nil { + res.Errs = append(res.Errs, err) + } else { + res.Context = resp } - if p.IdentityURL != "" && idReq != nil { - calls = append(calls, func() { - defer func() { - if r := recover(); r != nil { - mu.Lock() - res.Errs = append(res.Errs, fmt.Errorf("panic in identity call: %v", r)) - mu.Unlock() - logger.Errorf("adcontextprotocol.tmp: panic in identity call to %s: %v", p.Name, r) - } - }() - resp, err := m.callIdentity(pCtx, p, idReq) + }) + } + if idReq != nil { + calls = append(calls, func() { + defer func() { + if r := recover(); r != nil { mu.Lock() - defer mu.Unlock() - if err != nil { - res.Errs = append(res.Errs, err) - } else { - res.Identity = resp - } - }) - } - - rand.Shuffle(len(calls), func(a, b int) { calls[a], calls[b] = calls[b], calls[a] }) - maxDelay := m.cfg.DecorrelationMaxDelayMs - for idx, call := range calls { - innerWG.Go(func() { - if idx > 0 && maxDelay > 0 { - delay := time.Duration(rand.IntN(maxDelay+1)) * time.Millisecond - select { - case <-time.After(delay): - case <-pCtx.Done(): - return - } - } - call() - }) + res.Errs = append(res.Errs, fmt.Errorf("panic in identity call: %v", r)) + mu.Unlock() + logger.Errorf("adcontextprotocol.tmp: panic in identity call to %s: %v", p.Name, r) + } + }() + resp, err := m.callIdentity(pCtx, p, idReq) + mu.Lock() + defer mu.Unlock() + if err != nil { + res.Errs = append(res.Errs, err) + } else { + res.Identity = resp } - innerWG.Wait() - results[i] = res - }(i, p) + }) } - wg.Wait() - return &routerResult{ - Providers: results, - Segments: mergeSegments(results), + rand.Shuffle(len(calls), func(a, b int) { calls[a], calls[b] = calls[b], calls[a] }) + maxDelay := m.cfg.DecorrelationMaxDelayMs + for idx, call := range calls { + innerWG.Go(func() { + if idx > 0 && maxDelay > 0 { + delay := time.Duration(rand.IntN(maxDelay+1)) * time.Millisecond + select { + case <-time.After(delay): + case <-pCtx.Done(): + return + } + } + call() + }) } + innerWG.Wait() + return res } // mergeSegments joins each provider's context offers with its identity // eligibility and flattens the survivors into "key=value" strings suitable // for prebid targeting. Response-level signals from the context response are -// passed through as targeting keys directly. -func mergeSegments(results []providerResult) []string { +// passed through as targeting keys directly. Capped at cfg.MaxSegments and +// per-value length so a hostile provider cannot bloat the bid response. +// +// Fail-closed on identity error: when a provider was asked to do identity +// gating (URL configured + tokens present) and the call did not return a +// response, we drop all its offers. Snapshot of a hostile-or-flaky +// identity endpoint therefore cannot convert identity-gated packages +// into unconditionally-served packages. +func (m *Module) mergeSegments(results []providerResult) []string { + maxLen := m.cfg.MaxSegmentValueLen + maxCount := m.cfg.MaxSegments + var out []string for _, r := range results { if r.Context == nil { continue } + // Fail closed on identity-attempted-but-errored: eligibility + // cannot be established, so no offers pass. + if r.IdentityAttempted && r.Identity == nil { + continue + } eligible := eligibilitySet(r.Identity) filterEligibility := r.Identity != nil @@ -230,19 +262,53 @@ func mergeSegments(results []providerResult) []string { continue } } - out = append(out, r.Name+"_package="+offer.PackageID) + if len(out) >= maxCount { + return out + } + out = append(out, boundedSegment(r.Name+"_package="+offer.PackageID, maxLen)) } for k, v := range r.Context.Signals { - if v == nil { + if len(out) >= maxCount { + return out + } + str, ok := stringifySignal(v) + if !ok { continue } - out = append(out, r.Name+"_"+k+"="+fmt.Sprint(v)) + out = append(out, boundedSegment(r.Name+"_"+k+"="+str, maxLen)) } } return out } +// stringifySignal accepts scalar signal values (string, bool, number) +// and rejects non-scalars — a map or slice from a hostile provider +// would flow into targeting as "map[…]" garbage otherwise. +func stringifySignal(v any) (string, bool) { + if v == nil { + return "", false + } + switch x := v.(type) { + case string: + return x, true + case bool: + return fmt.Sprintf("%t", x), true + case float64, float32, int, int64, int32: + return fmt.Sprintf("%v", x), true + } + return "", false +} + +// boundedSegment truncates the segment string to the configured cap so +// a hostile provider cannot make single segments arbitrarily large. +func boundedSegment(s string, maxLen int) string { + if maxLen <= 0 || len(s) <= maxLen { + return s + } + return s[:maxLen] +} + func eligibilitySet(idResp *tmproto.IdentityMatchResponse) map[string]bool { if idResp == nil { return nil diff --git a/modules/adcontextprotocol/tmp/router_test.go b/modules/adcontextprotocol/tmp/router_test.go index 77fd1657287..50fc73f965e 100644 --- a/modules/adcontextprotocol/tmp/router_test.go +++ b/modules/adcontextprotocol/tmp/router_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strings" "sync" "testing" "time" @@ -121,7 +122,7 @@ func TestFanOut_JoinsContextAndIdentity(t *testing.T) { f := newFixture(t) defer f.Close() - res := f.Module.fanOut(context.Background(), sampleBidRequest()) + res := f.Module.fanOut(context.Background(), deriveInputs(&f.Module.cfg, sampleBidRequest())) if res == nil || len(res.Segments) == 0 { t.Fatalf("expected segments, got %+v", res) } @@ -151,7 +152,7 @@ func TestFanOut_ContextOnlyWhenNoIdentityTokens(t *testing.T) { req := sampleBidRequest() req.User = nil // no eids - res := f.Module.fanOut(context.Background(), req) + res := f.Module.fanOut(context.Background(), deriveInputs(&f.Module.cfg, req)) if res == nil || len(res.Segments) == 0 { t.Fatalf("expected segments even without identity; got %+v", res) } @@ -185,7 +186,7 @@ func TestFanOut_UnknownDomainReturnsEmpty(t *testing.T) { TimeoutMs: 500, }, nil) - res := f.Module.fanOut(context.Background(), sampleBidRequest()) + res := f.Module.fanOut(context.Background(), deriveInputs(&f.Module.cfg, sampleBidRequest())) if res == nil { t.Fatal("expected non-nil result") } @@ -201,7 +202,7 @@ func TestFanOut_EmptyPlacementIDShortCircuits(t *testing.T) { req := sampleBidRequest() req.Imp = nil - res := f.Module.fanOut(context.Background(), req) + res := f.Module.fanOut(context.Background(), deriveInputs(&f.Module.cfg, req)) if res == nil { t.Fatal("expected non-nil result") } @@ -210,44 +211,110 @@ func TestFanOut_EmptyPlacementIDShortCircuits(t *testing.T) { } } -func TestFanOut_ProviderPanicRecovered(t *testing.T) { +// Provider decode error must surface as fan-out completing with empty +// segments — not a crash, not a hang. This is the low-level "error +// tolerance" test; genuine panic-recovery is exercised by +// TestFanOut_PanickingRoundTripper below. +func TestFanOut_ProviderDecodeErrorSurvives(t *testing.T) { f := newFixture(t) defer f.Close() - // Make the provider hang up mid-response so JSON decode panics on some - // corrupt payload — but more simply, close the connection. - f.ContextHandler = func(w http.ResponseWriter, r *http.Request) { - panic("simulated context handler panic") - } - f.IdentHandler = func(w http.ResponseWriter, r *http.Request) { - panic("simulated identity handler panic") - } - - // httptest recovers server-side panics, so this only exercises client-side - // recovery when the response body is malformed. We swap in a handler that - // returns garbage JSON that decodes to an empty struct, and verify the - // module keeps returning a non-nil routerResult (i.e. no crash). - f.ContextHandler = func(w http.ResponseWriter, r *http.Request) { + f.ContextHandler = func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("not json")) } - f.IdentHandler = func(w http.ResponseWriter, r *http.Request) { + f.IdentHandler = func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("not json")) } - res := f.Module.fanOut(context.Background(), sampleBidRequest()) + res := f.Module.fanOut(context.Background(), deriveInputs(&f.Module.cfg, sampleBidRequest())) if res == nil { t.Fatal("expected non-nil result even when both provider calls error") } + if res.ErrCount != 1 { + t.Errorf("expected 1 provider with errors; got %d", res.ErrCount) + } + if len(res.Segments) != 0 { + t.Errorf("no segments expected on total decode failure; got %v", res.Segments) + } +} + +// panickingRoundTripper panics inside RoundTrip. The fan-out's inner +// goroutine must recover, record the error, and let the sibling call +// complete instead of taking the process down. +type panickingRoundTripper struct{} + +func (panickingRoundTripper) RoundTrip(*http.Request) (*http.Response, error) { + panic("boom in RoundTrip") +} + +func TestFanOut_PanickingRoundTripper(t *testing.T) { + f := newFixture(t) + defer f.Close() + // Only the context path panics; identity still works via the fixture's + // default handler. If panic recovery is broken this either crashes + // the test process or leaves the fan-out wedged forever. + f.Module.http = &http.Client{Transport: panickingRoundTripper{}} + + done := make(chan *routerResult, 1) + go func() { + done <- f.Module.fanOut(context.Background(), deriveInputs(&f.Module.cfg, sampleBidRequest())) + }() + + select { + case res := <-done: + if res == nil { + t.Fatal("expected non-nil result after panic recovery") + } + if res.ErrCount != 1 { + t.Errorf("expected 1 provider with errors; got %d", res.ErrCount) + } + case <-time.After(2 * time.Second): + t.Fatal("fan-out did not complete after transport panic — recovery is broken") + } } +// Fail-closed: identity call errors → offers dropped, not emitted +// unfiltered. Confirms the fix for review finding #3. +func TestFanOut_IdentityErrorDropsOffers(t *testing.T) { + f := newFixture(t) + defer f.Close() + + f.IdentHandler = func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + } + // Context returns real offers. + f.ContextHandler = func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(tmproto.ContextMatchResponse{ + Type: "context_match_response", + RequestID: "req", + Offers: []tmproto.Offer{{PackageID: "pkg-a"}, {PackageID: "pkg-b"}}, + }) + } + + res := f.Module.fanOut(context.Background(), deriveInputs(&f.Module.cfg, sampleBidRequest())) + if res == nil { + t.Fatal("expected non-nil result") + } + for _, s := range res.Segments { + if strings.Contains(s, "prov_package=") { + t.Errorf("expected no package segments when identity call errored; got %q", s) + } + } +} + +// Randomization is only observable if the second-to-spawn call is +// noticeably delayed vs the first — otherwise HTTP arrival order at the +// fake server is scheduler noise, not evidence of a shuffle. Set a +// large DecorrelationMaxDelayMs so the second call deterministically +// sleeps up to N ms before its HTTP round-trip; assert both orderings +// appear across enough iterations that a broken shuffle fails loudly. func TestFanOut_RandomizesContextIdentityOrder(t *testing.T) { f := newFixture(t) defer f.Close() + f.Module.cfg.DecorrelationMaxDelayMs = 30 var mu sync.Mutex - seen := map[string]int{} // "context-first" / "identity-first" - // Track which endpoint each request hit; whichever handler fires first - // per iteration determines the order for that iteration. + seen := map[string]int{} var currentIteration string setFirst := func(kind string) { mu.Lock() @@ -256,21 +323,21 @@ func TestFanOut_RandomizesContextIdentityOrder(t *testing.T) { currentIteration = kind } } - f.ContextHandler = func(w http.ResponseWriter, r *http.Request) { + f.ContextHandler = func(w http.ResponseWriter, _ *http.Request) { setFirst("context") _ = json.NewEncoder(w).Encode(tmproto.ContextMatchResponse{Type: "context_match_response", Offers: []tmproto.Offer{{PackageID: "pkg"}}}) } - f.IdentHandler = func(w http.ResponseWriter, r *http.Request) { + f.IdentHandler = func(w http.ResponseWriter, _ *http.Request) { setFirst("identity") _ = json.NewEncoder(w).Encode(tmproto.IdentityMatchResponse{Type: "identity_match_response", EligiblePackageIDs: []string{"pkg"}}) } - const iterations = 200 + const iterations = 40 for range iterations { mu.Lock() currentIteration = "" mu.Unlock() - _ = f.Module.fanOut(context.Background(), sampleBidRequest()) + _ = f.Module.fanOut(context.Background(), deriveInputs(&f.Module.cfg, sampleBidRequest())) mu.Lock() if currentIteration != "" { seen[currentIteration+"-first"]++ @@ -278,13 +345,11 @@ func TestFanOut_RandomizesContextIdentityOrder(t *testing.T) { mu.Unlock() } - // Both orderings must appear at least once across 200 iterations. The - // probability of a single-ordering run is (1/2)^200 — effectively zero. if seen["context-first"] == 0 { - t.Errorf("context never fired first across %d iterations; ordering is not randomized", iterations) + t.Errorf("context never fired first across %d iterations; shuffle is not randomizing order", iterations) } if seen["identity-first"] == 0 { - t.Errorf("identity never fired first across %d iterations; ordering is not randomized", iterations) + t.Errorf("identity never fired first across %d iterations; shuffle is not randomizing order", iterations) } } @@ -296,7 +361,7 @@ func TestFanOut_DecorrelationDelayDisabledByDefault(t *testing.T) { } start := time.Now() - _ = f.Module.fanOut(context.Background(), sampleBidRequest()) + _ = f.Module.fanOut(context.Background(), deriveInputs(&f.Module.cfg, sampleBidRequest())) elapsed := time.Since(start) // With the delay off, a healthy in-process fixture should complete well // under 100 ms. A generous bound catches regressions without being flaky. @@ -319,7 +384,7 @@ func TestFanOut_SigningHeadersOnOutbound(t *testing.T) { _ = json.NewEncoder(w).Encode(tmproto.IdentityMatchResponse{Type: "identity_match_response", EligiblePackageIDs: []string{"pkg"}}) } - _ = f.Module.fanOut(context.Background(), sampleBidRequest()) + _ = f.Module.fanOut(context.Background(), deriveInputs(&f.Module.cfg, sampleBidRequest())) if sawSig == "" { t.Error("expected X-AdCP-Signature to be set on outbound context call") } From 6bd5c30f11c6df7eb6d130df285690679be95722 Mon Sep 17 00:00:00 2001 From: Oleksandr Halushchak Date: Thu, 16 Jul 2026 17:52:48 +0200 Subject: [PATCH 05/11] Drop provider-name prefix; surface packages, signals, offer macros, TMPX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prior shape emitted every segment as `_=`, which doesn't match how ESA / agentic-api-onboarded publishers configure GAM — their line items target on a well-known custom key (`adcp_package_id`) holding the raw package_id IN-list, not on `_package`. Reworked mergeSegments to cover the four surfaces the AdCP TMP spec calls out (see adcp-go tmproto/types_gen.go): 1. Matched package IDs → single configurable key (default `adcp_package_id`), comma-joined and deduplicated across every provider that responded. 2. ContextMatchResponse.Signals → raw keys. 3. Offer.Macros → raw keys, per-offer creative macros. 4. IdentityMatchResponse.TmpxMacros[] → each macro's Name=Value verbatim. Names are already provider-namespaced upstream via the provider's registered tmpx_macros list; no transformation. Fail-closed on identity error now suppresses TMPX macros too — a flaky identity endpoint cannot inject a token onto an impression whose eligibility gate should have blocked it. Cross-provider collisions on non-package keys are last-wins with a warn log naming both providers, so operators can spot config drift. Co-Authored-By: Claude Opus 4.7 (1M context) --- modules/adcontextprotocol/tmp/config.go | 21 ++- modules/adcontextprotocol/tmp/config_test.go | 3 + modules/adcontextprotocol/tmp/hooks_test.go | 4 +- modules/adcontextprotocol/tmp/router.go | 103 ++++++++++-- modules/adcontextprotocol/tmp/router_test.go | 158 ++++++++++++++++--- 5 files changed, 249 insertions(+), 40 deletions(-) diff --git a/modules/adcontextprotocol/tmp/config.go b/modules/adcontextprotocol/tmp/config.go index c859992a706..a79310b31e3 100644 --- a/modules/adcontextprotocol/tmp/config.go +++ b/modules/adcontextprotocol/tmp/config.go @@ -53,11 +53,23 @@ type Config struct { Masking MaskingConfig `json:"masking"` // TargetingKey is the ext key on the bid response under which we surface - // merged TMP signals. Defaults to "adcp". + // the raw merged TMP segment list (a []string of "key=value" pairs, useful + // for callers that consume the response.ext directly). Defaults to "adcp". TargetingKey string `json:"targeting_key"` - // AddToTargeting mirrors the response signals into prebid.targeting so - // downstream ad servers (e.g. GAM) can consume them. + // PackageTargetingKey is the single custom key under which the module + // emits all matched package_ids on prebid.targeting, comma-joined and + // deduplicated across every provider that responded. Ad-server line items + // target on this key with IN semantics (e.g. GAM: adcp_package_id ∈ + // {pkg_a, pkg_b}). Defaults to "adcp_package_id"; set to "" to disable + // package emission on prebid.targeting. + PackageTargetingKey string `json:"package_targeting_key"` + + // AddToTargeting mirrors merged package IDs, context response signals, + // per-offer creative macros, and identity TMPX macros into + // prebid.targeting so downstream ad servers (GAM, VAST URL macros, DOOH + // play-log fields) can consume them. Keys are emitted as the agents + // return them — no provider-name prefixing. AddToTargeting bool `json:"add_to_targeting"` // MaxSegments caps the total number of segments emitted onto the @@ -209,6 +221,9 @@ func (c *Config) validated() (ed25519.PrivateKey, error) { if c.TargetingKey == "" { c.TargetingKey = "adcp" } + if c.PackageTargetingKey == "" { + c.PackageTargetingKey = "adcp_package_id" + } if c.MaxSegments <= 0 { c.MaxSegments = 128 } diff --git a/modules/adcontextprotocol/tmp/config_test.go b/modules/adcontextprotocol/tmp/config_test.go index fbfd0faf63b..d2e7f27236d 100644 --- a/modules/adcontextprotocol/tmp/config_test.go +++ b/modules/adcontextprotocol/tmp/config_test.go @@ -60,6 +60,9 @@ func TestValidated_Defaults(t *testing.T) { if cfg.TargetingKey != "adcp" { t.Errorf("TargetingKey default = %q, want %q", cfg.TargetingKey, "adcp") } + if cfg.PackageTargetingKey != "adcp_package_id" { + t.Errorf("PackageTargetingKey default = %q, want %q", cfg.PackageTargetingKey, "adcp_package_id") + } } func TestValidated_ProviderNeedsAtLeastOneURL(t *testing.T) { diff --git a/modules/adcontextprotocol/tmp/hooks_test.go b/modules/adcontextprotocol/tmp/hooks_test.go index 7d28d249ed0..9054dd25070 100644 --- a/modules/adcontextprotocol/tmp/hooks_test.go +++ b/modules/adcontextprotocol/tmp/hooks_test.go @@ -163,8 +163,8 @@ func TestHooks_EndToEndDeliversSegments(t *testing.T) { // The exact JSON path is `adcp.segments`; a substring search is // enough — the strict assertion is "some segment survived the // hook plumbing", which is the invariant that broke. - if !bytesContains(payload.BidResponse.Ext, "prov_package=pkg-a") { - t.Errorf("expected prov_package=pkg-a in response ext; got %s", string(payload.BidResponse.Ext)) + if !bytesContains(payload.BidResponse.Ext, "adcp_package_id=pkg-a") { + t.Errorf("expected adcp_package_id=pkg-a in response ext; got %s", string(payload.BidResponse.Ext)) } } diff --git a/modules/adcontextprotocol/tmp/router.go b/modules/adcontextprotocol/tmp/router.go index d7893aa7fb3..431a2a6ba25 100644 --- a/modules/adcontextprotocol/tmp/router.go +++ b/modules/adcontextprotocol/tmp/router.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "math/rand/v2" + "strings" "sync" "time" @@ -230,20 +231,53 @@ func (m *Module) callProvider( // mergeSegments joins each provider's context offers with its identity // eligibility and flattens the survivors into "key=value" strings suitable -// for prebid targeting. Response-level signals from the context response are -// passed through as targeting keys directly. Capped at cfg.MaxSegments and -// per-value length so a hostile provider cannot bloat the bid response. +// for prebid targeting. The emitted segments cover four surfaces the AdCP +// TMP spec calls out (see adcp docs/trusted-match/specification.mdx and +// adcp-go tmproto/types_gen.go): +// +// 1. Matched package IDs → cfg.PackageTargetingKey, comma-joined and +// deduplicated across providers. Empty PackageTargetingKey disables +// this line entirely. +// 2. ContextMatchResponse.Signals → raw keys (last-wins on collision +// across providers, with an emitted warn segment recording the loser). +// 3. Offer.Macros (per-offer creative macros) → raw keys. +// 4. IdentityMatchResponse.TmpxMacros[] → each TmpxMacro's own Name as +// the key, Value verbatim. Names are provider-namespaced upstream in +// the provider's registered tmpx_macros list; no transformation here. +// +// Capped at cfg.MaxSegments and per-value length so a hostile provider +// cannot bloat the bid response. // // Fail-closed on identity error: when a provider was asked to do identity // gating (URL configured + tokens present) and the call did not return a -// response, we drop all its offers. Snapshot of a hostile-or-flaky -// identity endpoint therefore cannot convert identity-gated packages -// into unconditionally-served packages. +// response, we drop all its offers AND its TMPX macros. A hostile-or-flaky +// identity endpoint therefore cannot convert identity-gated packages into +// unconditionally-served packages, and cannot inject a TMPX token into the +// bid response by failing partway through. func (m *Module) mergeSegments(results []providerResult) []string { maxLen := m.cfg.MaxSegmentValueLen maxCount := m.cfg.MaxSegments + pkgKey := m.cfg.PackageTargetingKey var out []string + appendSeg := func(s string) bool { + if len(out) >= maxCount { + return false + } + out = append(out, boundedSegment(s, maxLen)) + return true + } + + // Signal-key tracking so we can log collisions across providers. + // The producing provider's name is not exposed in the segment value, + // only in the module's own log line — targeting keys stay clean. + signalOwner := map[string]string{} + + // Package IDs: collect from every eligible offer, dedup, comma-join. + // Order preserved by first-emission so tests are stable. + var pkgIDs []string + seenPkg := map[string]bool{} + for _, r := range results { if r.Context == nil { continue @@ -257,26 +291,63 @@ func (m *Module) mergeSegments(results []providerResult) []string { filterEligibility := r.Identity != nil for _, offer := range r.Context.Offers { - if filterEligibility { - if !eligible[offer.PackageID] { + if filterEligibility && !eligible[offer.PackageID] { + continue + } + if !seenPkg[offer.PackageID] { + seenPkg[offer.PackageID] = true + pkgIDs = append(pkgIDs, offer.PackageID) + } + // Per-offer creative macros. Only surfaced for eligible offers so + // a provider cannot leak macros for packages the identity gate + // filtered out. + for k, v := range offer.Macros { + if v == "" { continue } + if prev, dup := signalOwner[k]; dup && prev != r.Name { + logger.Warnf("adcontextprotocol.tmp: offer macro key %q from %q overwrites earlier value from %q", k, r.Name, prev) + } + signalOwner[k] = r.Name + if !appendSeg(k + "=" + v) { + return out + } } - if len(out) >= maxCount { - return out - } - out = append(out, boundedSegment(r.Name+"_package="+offer.PackageID, maxLen)) } for k, v := range r.Context.Signals { - if len(out) >= maxCount { - return out - } str, ok := stringifySignal(v) if !ok { continue } - out = append(out, boundedSegment(r.Name+"_"+k+"="+str, maxLen)) + if prev, dup := signalOwner[k]; dup && prev != r.Name { + logger.Warnf("adcontextprotocol.tmp: context signal key %q from %q overwrites earlier value from %q", k, r.Name, prev) + } + signalOwner[k] = r.Name + if !appendSeg(k + "=" + str) { + return out + } + } + + // Identity TMPX macros. Names are already provider-namespaced by the + // provider's registered tmpx_macros list (see adcp-go + // tmproto/types_gen.go ProviderRegistration.TmpxMacros), so no key + // transformation here — pass Name=Value verbatim to the ad server. + if r.Identity != nil { + for _, tm := range r.Identity.TmpxMacros { + if tm.Name == "" || tm.Value == "" { + continue + } + if !appendSeg(tm.Name + "=" + tm.Value) { + return out + } + } + } + } + + if pkgKey != "" && len(pkgIDs) > 0 { + if !appendSeg(pkgKey + "=" + strings.Join(pkgIDs, ",")) { + return out } } return out diff --git a/modules/adcontextprotocol/tmp/router_test.go b/modules/adcontextprotocol/tmp/router_test.go index 50fc73f965e..b137e2fddce 100644 --- a/modules/adcontextprotocol/tmp/router_test.go +++ b/modules/adcontextprotocol/tmp/router_test.go @@ -127,20 +127,22 @@ func TestFanOut_JoinsContextAndIdentity(t *testing.T) { t.Fatalf("expected segments, got %+v", res) } // pkg-b should be filtered out because identity only returned pkg-a. - sawPkgA := false - sawPkgB := false + // Package IDs land as a single comma-joined entry under the configured + // PackageTargetingKey (default adcp_package_id). + var pkgSeg string for _, s := range res.Segments { - if s == "prov_package=pkg-a" { - sawPkgA = true - } - if s == "prov_package=pkg-b" { - sawPkgB = true + if strings.HasPrefix(s, "adcp_package_id=") { + pkgSeg = s + break } } - if !sawPkgA { - t.Errorf("expected prov_package=pkg-a in segments; got %v", res.Segments) + if pkgSeg == "" { + t.Errorf("expected adcp_package_id=... in segments; got %v", res.Segments) + } + if !strings.Contains(pkgSeg, "pkg-a") { + t.Errorf("expected pkg-a in %q; got %v", pkgSeg, res.Segments) } - if sawPkgB { + if strings.Contains(pkgSeg, "pkg-b") { t.Errorf("pkg-b should have been filtered by identity eligibility; got %v", res.Segments) } } @@ -157,17 +159,15 @@ func TestFanOut_ContextOnlyWhenNoIdentityTokens(t *testing.T) { t.Fatalf("expected segments even without identity; got %+v", res) } // Both packages should be present because identity eligibility is not enforced. - sawA, sawB := false, false + var pkgSeg string for _, s := range res.Segments { - if s == "prov_package=pkg-a" { - sawA = true - } - if s == "prov_package=pkg-b" { - sawB = true + if strings.HasPrefix(s, "adcp_package_id=") { + pkgSeg = s + break } } - if !sawA || !sawB { - t.Errorf("expected both packages without identity; got %v", res.Segments) + if !strings.Contains(pkgSeg, "pkg-a") || !strings.Contains(pkgSeg, "pkg-b") { + t.Errorf("expected both pkg-a and pkg-b in %q; got %v", pkgSeg, res.Segments) } } @@ -296,7 +296,7 @@ func TestFanOut_IdentityErrorDropsOffers(t *testing.T) { t.Fatal("expected non-nil result") } for _, s := range res.Segments { - if strings.Contains(s, "prov_package=") { + if strings.HasPrefix(s, "adcp_package_id=") { t.Errorf("expected no package segments when identity call errored; got %q", s) } } @@ -392,3 +392,123 @@ func TestFanOut_SigningHeadersOnOutbound(t *testing.T) { t.Errorf("X-AdCP-Key-Id = %q, want kid-1", sawKid) } } + +// TestMergeSegments_TMPXAndOfferMacros verifies the four spec surfaces the +// module surfaces onto prebid targeting: package IDs (comma-joined under +// the configurable single key), per-offer creative macros, response-level +// context signals, and identity TMPX macros — each with its raw key intact, +// no provider-name prefix. +func TestMergeSegments_TMPXAndOfferMacros(t *testing.T) { + m := &Module{cfg: Config{ + PackageTargetingKey: "adcp_package_id", + MaxSegments: 64, + MaxSegmentValueLen: 256, + }} + results := []providerResult{{ + Name: "prov", + Context: &tmproto.ContextMatchResponse{ + Offers: []tmproto.Offer{ + {PackageID: "pkg-a", Macros: map[string]string{"brand": "Acme"}}, + {PackageID: "pkg-b"}, + }, + Signals: map[string]any{"iab_cat": "sports"}, + }, + Identity: &tmproto.IdentityMatchResponse{ + EligiblePackageIDs: []string{"pkg-a", "pkg-b"}, + TmpxMacros: []tmproto.TmpxMacro{ + {Name: "SCOPE3_TMPX_1", Value: "opaque-chunk-1"}, + }, + }, + }} + + out := m.mergeSegments(results) + want := map[string]string{ + "brand": "Acme", + "iab_cat": "sports", + "SCOPE3_TMPX_1": "opaque-chunk-1", + "adcp_package_id": "pkg-a,pkg-b", + } + for _, s := range out { + kv := strings.SplitN(s, "=", 2) + if len(kv) != 2 { + t.Errorf("malformed segment %q", s) + continue + } + if want[kv[0]] != kv[1] { + t.Errorf("segment %q: want %q for key %q", s, want[kv[0]], kv[0]) + } + delete(want, kv[0]) + } + if len(want) != 0 { + t.Errorf("missing expected keys: %v (got %v)", want, out) + } +} + +// TestMergeSegments_PackageIDsDedupedAcrossProviders confirms two providers +// returning the same PackageID collapse into a single value in the joined +// key — otherwise GAM's IN-targeting list would carry duplicates. +func TestMergeSegments_PackageIDsDedupedAcrossProviders(t *testing.T) { + m := &Module{cfg: Config{ + PackageTargetingKey: "adcp_package_id", + MaxSegments: 64, + MaxSegmentValueLen: 256, + }} + results := []providerResult{ + {Name: "a", Context: &tmproto.ContextMatchResponse{Offers: []tmproto.Offer{{PackageID: "pkg-1"}, {PackageID: "pkg-2"}}}}, + {Name: "b", Context: &tmproto.ContextMatchResponse{Offers: []tmproto.Offer{{PackageID: "pkg-2"}, {PackageID: "pkg-3"}}}}, + } + + out := m.mergeSegments(results) + var pkgSeg string + for _, s := range out { + if strings.HasPrefix(s, "adcp_package_id=") { + pkgSeg = s + } + } + if pkgSeg != "adcp_package_id=pkg-1,pkg-2,pkg-3" { + t.Errorf("expected pkg-1,pkg-2,pkg-3 (deduped, first-seen order); got %q", pkgSeg) + } +} + +// TestMergeSegments_EmptyPackageKeyDisables verifies that setting +// PackageTargetingKey to "" omits the package line entirely — the escape +// hatch for operators whose ad server doesn't want it. +func TestMergeSegments_EmptyPackageKeyDisables(t *testing.T) { + m := &Module{cfg: Config{ + PackageTargetingKey: "", + MaxSegments: 64, + MaxSegmentValueLen: 256, + }} + out := m.mergeSegments([]providerResult{{ + Name: "prov", + Context: &tmproto.ContextMatchResponse{Offers: []tmproto.Offer{{PackageID: "pkg-a"}}}, + }}) + for _, s := range out { + if strings.HasPrefix(s, "adcp_package_id=") || strings.Contains(s, "package") { + t.Errorf("expected no package segment when PackageTargetingKey is empty; got %q", s) + } + } +} + +// TestMergeSegments_FailClosedDropsTMPX confirms the fail-closed path also +// suppresses TMPX macros. If the identity call errored the module has no +// way to know if the token is authorized for the request, so the safe +// answer is to drop it — otherwise a flaky identity endpoint could leak +// a token onto an impression the eligibility gate would have blocked. +func TestMergeSegments_FailClosedDropsTMPX(t *testing.T) { + m := &Module{cfg: Config{ + PackageTargetingKey: "adcp_package_id", + MaxSegments: 64, + MaxSegmentValueLen: 256, + }} + results := []providerResult{{ + Name: "prov", + Context: &tmproto.ContextMatchResponse{Offers: []tmproto.Offer{{PackageID: "pkg-a"}}}, + IdentityAttempted: true, + Identity: nil, + }} + out := m.mergeSegments(results) + if len(out) != 0 { + t.Errorf("expected empty segments on identity-attempted-but-failed; got %v", out) + } +} From e99359bd6aac939294b46ae89f8db90716851f27 Mon Sep 17 00:00:00 2001 From: Oleksandr Halushchak Date: Thu, 6 Aug 2026 12:14:50 +0200 Subject: [PATCH 06/11] Adopt publisher-owned TMPX macro mapping; pin adcp-go tmproto v0.1.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The identity-agent response shape moved from IdentityMatchResponse.TmpxMacros[] (provider-named ad-server macros) to ProviderIdentityMatchResponse.TmpxChunks[] (provider-local slot IDs + opaque values). The publisher, not the provider, now owns the ad-server destination namespace via a deployment-config map keyed on (provider_id, slot_id). This module IS the router in that model, so: - provider_client decodes ProviderIdentityMatchResponse. - router.mergeSegments iterates TmpxChunks[] and resolves each chunk's (provider, slot_id) against a new publisher-owned tmpx_macro_mapping config. Unmapped slots drop the whole provider's chunks atomically for that impression (fail-closed per adcp publisher-tmpx-config.json). - Providers absent from the mapping emit no TMPX targeting on this surface — the mapping is per-Prebid-Server deployment. - Config validation rejects mapping entries that reference providers not declared in providers[] or that carry empty slot/macro strings. Pin the specific adcp-go sub-modules the code imports (tmproto v0.1.0 plus its urlcanon transitive) instead of a pseudo-versioned root module, matching the module boundary the SDK now publishes. --- go.mod | 4 +- go.sum | 32 +++--- modules/adcontextprotocol/tmp/README.md | 41 +++++++- modules/adcontextprotocol/tmp/config.go | 64 ++++++++++-- modules/adcontextprotocol/tmp/config_test.go | 34 +++++++ modules/adcontextprotocol/tmp/hooks_test.go | 2 +- .../adcontextprotocol/tmp/provider_client.go | 8 +- modules/adcontextprotocol/tmp/router.go | 71 ++++++++++---- modules/adcontextprotocol/tmp/router_test.go | 97 +++++++++++++++++-- 9 files changed, 294 insertions(+), 59 deletions(-) diff --git a/go.mod b/go.mod index 9e39857345e..fede34f1db2 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( github.com/IABTechLab/adscert v0.34.0 github.com/NYTimes/gziphandler v1.1.1 github.com/WURFL/golang-wurfl v1.30.3 - github.com/adcontextprotocol/adcp-go v0.0.0-20260703103742-c8f541ba6888 + github.com/adcontextprotocol/adcp-go/tmproto v0.1.0 github.com/alitto/pond v1.8.3 github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d github.com/benbjohnson/clock v1.3.0 @@ -49,10 +49,12 @@ require ( ) require ( + github.com/adcontextprotocol/adcp-go/urlcanon v0.1.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/fsnotify/fsnotify v1.5.4 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/influxdata/influxdb1-client v0.0.0-20191209144304-8bf82d3c094d // indirect github.com/klauspost/cpuid/v2 v2.2.10 // indirect diff --git a/go.sum b/go.sum index 20ad3fe677b..88841a98cc3 100644 --- a/go.sum +++ b/go.sum @@ -63,8 +63,10 @@ github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMo github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/WURFL/golang-wurfl v1.30.3 h1:a/ZR+/mwMrA9cEVa88ig47zkVJNl3HM5OTCpPvoSYmE= github.com/WURFL/golang-wurfl v1.30.3/go.mod h1:cKXIyA0oIrbZ7YTOhBPX29ELt6XAM1/S7qyFIrTKkS0= -github.com/adcontextprotocol/adcp-go v0.0.0-20260703103742-c8f541ba6888 h1:fD/cUFNvjVbn9LCurcHXhnKsHVrzM9NzqmR8y6oJpe0= -github.com/adcontextprotocol/adcp-go v0.0.0-20260703103742-c8f541ba6888/go.mod h1:LgFcpyqcVaENPXZTrzP5M5jc6jltXo28zpCJ2qHG12c= +github.com/adcontextprotocol/adcp-go/tmproto v0.1.0 h1:7yuDHgCINlWsOXIqdzv1JYl0nlL3GswzNHi9R4ddJxQ= +github.com/adcontextprotocol/adcp-go/tmproto v0.1.0/go.mod h1:IB7hhgE9PT9bVGcJEtTo7Z5bBpYXXEoVkt8x375KaC8= +github.com/adcontextprotocol/adcp-go/urlcanon v0.1.0 h1:zmLYfwIQBr3dPq+fDXtIgUr7LNHu56LZhaZp3uVCzFo= +github.com/adcontextprotocol/adcp-go/urlcanon v0.1.0/go.mod h1:cWWYEAyTy6EAB/4nses21g3UNqzcxYEFRG8Hm7l1xiM= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= @@ -287,6 +289,8 @@ github.com/hashicorp/go-version v1.1.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09 github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= @@ -323,8 +327,8 @@ github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4d github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= -github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= @@ -554,16 +558,16 @@ go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= -go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= -go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= -go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= -go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= -go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= -go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= -go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= -go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= -go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= diff --git a/modules/adcontextprotocol/tmp/README.md b/modules/adcontextprotocol/tmp/README.md index 03359750650..c05ffad83f1 100644 --- a/modules/adcontextprotocol/tmp/README.md +++ b/modules/adcontextprotocol/tmp/README.md @@ -43,6 +43,21 @@ hooks: identity_url: https://tmp.example.com/identity context_url: https://tmp.example.com/context timeout_ms: 200 + # Publisher-owned deployment configuration that resolves each + # provider's ordered TMPX chunks (provider-local {slot_id, value} + # pairs, per adcp publisher-tmpx-config.json) to local ad-server + # macro names on this Prebid Server surface. Outer key is the + # provider's `name` above; inner key is the provider-local + # `slot_id` the provider registered in `tmpx_slots`; value is the + # publisher-local destination (GAM key, VAST URL macro, DOOH + # play-log field). Providers absent from this map emit no TMPX + # targeting. Chunks with an unmapped slot cause the whole + # provider's chunks to be dropped for that impression + # (fail-closed). + tmpx_macro_mapping: + example: + primary: TMPX_1 + secondary: TMPX_2 timeout_ms: 300 # Set to a positive value to jitter the second of a provider's context / # identity outbound calls by a random [0, N] ms, breaking timing @@ -103,8 +118,9 @@ hooks: | `signing.key_id` | Sent in `X-AdCP-Key-Id`. Verifiers use it to look up the matching Ed25519 public key. | | `signing.private_key_pem` | PEM-encoded PKCS#8 Ed25519 private key. | | `property_registry.endpoint` | Resolves `site.domain` / `app.bundle` → `property_rid` via a `GET ?domain=…` call. | -| `providers[].name` | Human-readable label; used as the prefix on emitted targeting keys. | +| `providers[].name` | Stable provider identifier. Appears verbatim in logs, metrics, and as the outer key of `tmpx_macro_mapping`. Charset: `[a-z0-9][a-z0-9_-]{0,31}`. | | `providers[].identity_url` or `providers[].context_url` | At least one is required per provider. | +| `tmpx_macro_mapping` | Optional. Publisher-owned map of `provider_name → slot_id → ad-server macro name` used to route each provider's TMPX chunks. Omit to disable TMPX targeting. | ### Providers @@ -131,16 +147,31 @@ cache. ## Response surface -Merged signals are written to the auction response `ext` under the configured -`targeting_key` (default `adcp`): +Merged targeting is written to the auction response `ext` under the configured +`targeting_key` (default `adcp`) as a flat list of `key=value` strings. Four +surfaces are covered per the adcp TMP spec: + +- **Package IDs** eligible under identity, comma-joined under + `package_targeting_key` (default `adcp_package_id`). +- **Response-level context signals** — the identity-agent-neutral + `ContextMatchResponse.signals` map, one `key=value` per scalar entry. +- **Per-offer creative macros** — `Offer.macros` for offers that survived + the identity eligibility gate. +- **Identity TMPX chunks** resolved through `tmpx_macro_mapping`. Providers + emit `{slot_id, value}` pairs against their registered `tmpx_slots`; the + publisher's mapping decides the ad-server destination for each pair on + this surface. Chunks with unmapped slots are dropped atomically for that + provider (fail-closed). ```json { "ext": { "adcp": { "segments": [ - "example_package=pkg-fall-2026", - "example_segment=auto_intender" + "adcp_package_id=pkg-fall-2026,pkg-holiday", + "iab_cat=IAB1", + "brand=Acme", + "TMPX_1=opaque-chunk-value" ] } } diff --git a/modules/adcontextprotocol/tmp/config.go b/modules/adcontextprotocol/tmp/config.go index a79310b31e3..05afc460293 100644 --- a/modules/adcontextprotocol/tmp/config.go +++ b/modules/adcontextprotocol/tmp/config.go @@ -66,12 +66,42 @@ type Config struct { PackageTargetingKey string `json:"package_targeting_key"` // AddToTargeting mirrors merged package IDs, context response signals, - // per-offer creative macros, and identity TMPX macros into + // per-offer creative macros, and identity TMPX chunks into // prebid.targeting so downstream ad servers (GAM, VAST URL macros, DOOH - // play-log fields) can consume them. Keys are emitted as the agents - // return them — no provider-name prefixing. + // play-log fields) can consume them. Signal and offer-macro keys are + // emitted as the agents return them. Identity TMPX values are keyed on + // the publisher-local macro names resolved from TmpxMacroMapping — + // providers never name the destination. AddToTargeting bool `json:"add_to_targeting"` + // TmpxMacroMapping is the publisher-owned deployment configuration that + // resolves each provider's ordered TMPX chunks to local ad-server macro + // names (or targeting keys, VAST URL substitutions, DOOH play-log + // fields) on this Prebid Server surface. Outer map key is the + // provider's Name in this module's Providers[] (used as `provider_id` + // in the adcp TMP spec); inner map key is the provider-local `slot_id` + // the provider registered in `tmpx_slots`; value is the ad-server + // destination the publisher trafficks against. + // + // The map is authored by the same operator who trafficks the + // corresponding ad-server line items; it never travels on the wire + // between identity provider and this module. This keeps macro naming a + // deployment concern rather than a protocol identifier: a hostile or + // misconfigured identity provider cannot pick a macro name the + // publisher did not intend. + // + // At serve time, the router iterates + // ProviderIdentityMatchResponse.tmpx_chunks[] for each provider and + // emits macro=value for every chunk whose slot_id is present in the + // inner map. A chunk whose (provider, slot_id) is absent causes the + // whole provider's chunks to be dropped atomically for that impression + // (fail-closed per adcp publisher-tmpx-config.json). Providers with no + // entry in the outer map produce no TMPX targeting on this surface. + // + // See docs/trusted-match/specification.mdx and publisher-tmpx-config.json + // in the adcontextprotocol/adcp repo for the wire-level model. + TmpxMacroMapping map[string]map[string]string `json:"tmpx_macro_mapping"` + // MaxSegments caps the total number of segments emitted onto the // response ext, regardless of how many providers respond or how many // offers/signals they include. Default 128. A hostile-or-buggy @@ -151,12 +181,12 @@ type DeviceMaskingConfig struct { PreserveMobileIds bool `json:"preserve_mobile_ids"` } -// providerNameRE constrains provider names so an operator cannot -// accidentally name a provider "hb" (or similar) and have its emitted -// segment keys collide with Prebid's own reserved targeting keys (e.g. -// hb_pb, hb_adid). The prefix in emitted segments is provider name + -// underscore; restricting to a lower-case identifier keeps the prefix -// unambiguous. +// providerNameRE constrains provider names to a subset of the adcp +// provider_id charset (alphanumeric + underscore, up to 64 chars per the +// spec) so the name can appear verbatim in logs, metrics, and the outer +// key of TmpxMacroMapping without quoting or normalization mismatches. +// Restricted to lower-case for operational uniformity within a single +// deployment. var providerNameRE = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,31}$`) // validated returns a Config with defaults filled in, along with the parsed @@ -244,5 +274,21 @@ func (c *Config) validated() (ed25519.PrivateKey, error) { c.Masking.User.PreserveEids = []string{"liveramp.com", "uidapi.com", "id5-sync.com"} } } + for providerID, slotMap := range c.TmpxMacroMapping { + if !seenNames[providerID] { + return nil, fmt.Errorf("tmpx_macro_mapping refers to provider %q that is not in providers[]", providerID) + } + if len(slotMap) == 0 { + return nil, fmt.Errorf("tmpx_macro_mapping[%q] is empty; omit the provider to disable its TMPX targeting", providerID) + } + for slotID, macro := range slotMap { + if slotID == "" { + return nil, fmt.Errorf("tmpx_macro_mapping[%q]: slot_id must be non-empty", providerID) + } + if macro == "" { + return nil, fmt.Errorf("tmpx_macro_mapping[%q][%q]: destination macro must be non-empty", providerID, slotID) + } + } + } return priv, nil } diff --git a/modules/adcontextprotocol/tmp/config_test.go b/modules/adcontextprotocol/tmp/config_test.go index d2e7f27236d..1170ebb984d 100644 --- a/modules/adcontextprotocol/tmp/config_test.go +++ b/modules/adcontextprotocol/tmp/config_test.go @@ -113,3 +113,37 @@ func TestValidated_MaskingDefaultEIDList(t *testing.T) { t.Fatal("expected default EID list to be populated when masking is enabled") } } + +func TestValidated_TmpxMacroMappingRejectsUnknownProvider(t *testing.T) { + cfg := validConfig(t) + cfg.TmpxMacroMapping = map[string]map[string]string{ + "not-a-configured-provider": {"primary": "TMPX_1"}, + } + _, err := cfg.validated() + if err == nil { + t.Fatal("expected error when tmpx_macro_mapping references an unknown provider") + } + if !strings.Contains(err.Error(), "not-a-configured-provider") { + t.Errorf("expected error to name the offending provider; got %v", err) + } +} + +func TestValidated_TmpxMacroMappingRejectsEmptyMacro(t *testing.T) { + cfg := validConfig(t) + cfg.TmpxMacroMapping = map[string]map[string]string{ + "example": {"primary": ""}, + } + if _, err := cfg.validated(); err == nil { + t.Fatal("expected error when destination macro is empty") + } +} + +func TestValidated_TmpxMacroMappingAcceptsValidEntry(t *testing.T) { + cfg := validConfig(t) + cfg.TmpxMacroMapping = map[string]map[string]string{ + "example": {"primary": "TMPX_1", "secondary": "TMPX_2"}, + } + if _, err := cfg.validated(); err != nil { + t.Errorf("expected valid config, got %v", err) + } +} diff --git a/modules/adcontextprotocol/tmp/hooks_test.go b/modules/adcontextprotocol/tmp/hooks_test.go index 9054dd25070..3e74da68a8f 100644 --- a/modules/adcontextprotocol/tmp/hooks_test.go +++ b/modules/adcontextprotocol/tmp/hooks_test.go @@ -46,7 +46,7 @@ func newHooksFixtureModule(t *testing.T) (*Module, func()) { Offers: []tmproto.Offer{{PackageID: "pkg-a"}}, }) case "/identity": - _ = json.NewEncoder(w).Encode(tmproto.IdentityMatchResponse{ + _ = json.NewEncoder(w).Encode(tmproto.ProviderIdentityMatchResponse{ Type: "identity_match_response", RequestID: "req", EligiblePackageIDs: []string{"pkg-a"}, diff --git a/modules/adcontextprotocol/tmp/provider_client.go b/modules/adcontextprotocol/tmp/provider_client.go index 8c016ef173e..707b0a65b64 100644 --- a/modules/adcontextprotocol/tmp/provider_client.go +++ b/modules/adcontextprotocol/tmp/provider_client.go @@ -37,7 +37,11 @@ func (m *Module) callContext(ctx context.Context, p ProviderConfig, req *tmproto // callIdentity signs and POSTs an IdentityMatch request to the provider's // identity endpoint. The wire request keeps the Country field, but signing // strips it via BuildIdentityMatchSigningInput's canonical form. -func (m *Module) callIdentity(ctx context.Context, p ProviderConfig, req *tmproto.IdentityMatchRequest) (*tmproto.IdentityMatchResponse, error) { +// +// The provider→router hop returns ProviderIdentityMatchResponse (eligibility +// plus provider-local TMPX chunks). This module IS the router — publisher- +// facing shape reassembly happens locally via the TmpxMacroMapping config. +func (m *Module) callIdentity(ctx context.Context, p ProviderConfig, req *tmproto.IdentityMatchRequest) (*tmproto.ProviderIdentityMatchResponse, error) { epoch := tmproto.CurrentEpoch() endpoint := tmproto.NormalizeProviderEndpointURL(p.IdentityURL) sig, err := m.signer.SignIdentityMatch(req, endpoint, epoch) @@ -54,7 +58,7 @@ func (m *Module) callIdentity(ctx context.Context, p ProviderConfig, req *tmprot if err != nil { return nil, err } - var resp tmproto.IdentityMatchResponse + var resp tmproto.ProviderIdentityMatchResponse if err := jsonutil.Unmarshal(body, &resp); err != nil { return nil, fmt.Errorf("identity decode: %w", err) } diff --git a/modules/adcontextprotocol/tmp/router.go b/modules/adcontextprotocol/tmp/router.go index 431a2a6ba25..eaefe1f9537 100644 --- a/modules/adcontextprotocol/tmp/router.go +++ b/modules/adcontextprotocol/tmp/router.go @@ -19,7 +19,9 @@ type providerResult struct { // Context is set when the context call succeeded, nil otherwise. Context *tmproto.ContextMatchResponse // Identity is set when the identity call succeeded, nil otherwise. - Identity *tmproto.IdentityMatchResponse + // This is the provider→router shape (eligibility + provider-local + // TmpxChunks), not the router→publisher shape. + Identity *tmproto.ProviderIdentityMatchResponse // IdentityAttempted is true when the module actually issued an // identity call for this provider (URL configured AND tokens // present). Lets the merge distinguish "identity errored" (fail @@ -241,16 +243,23 @@ func (m *Module) callProvider( // 2. ContextMatchResponse.Signals → raw keys (last-wins on collision // across providers, with an emitted warn segment recording the loser). // 3. Offer.Macros (per-offer creative macros) → raw keys. -// 4. IdentityMatchResponse.TmpxMacros[] → each TmpxMacro's own Name as -// the key, Value verbatim. Names are provider-namespaced upstream in -// the provider's registered tmpx_macros list; no transformation here. +// 4. ProviderIdentityMatchResponse.TmpxChunks[] → each chunk resolved +// to a publisher-local ad-server macro via cfg.TmpxMacroMapping +// (keyed on provider.Name → slot_id → macro_name), emitted as +// macro_name=value. Provider→router carries `{slot_id, value}` +// pairs (opaque provider-local IDs); the publisher's deployment +// configuration owns the destination namespace. Chunks whose +// (provider, slot_id) are absent from the mapping are dropped +// atomically for that provider on that impression (fail closed), +// matching the router-conformance rule in adcp +// publisher-tmpx-config.json. // // Capped at cfg.MaxSegments and per-value length so a hostile provider // cannot bloat the bid response. // // Fail-closed on identity error: when a provider was asked to do identity // gating (URL configured + tokens present) and the call did not return a -// response, we drop all its offers AND its TMPX macros. A hostile-or-flaky +// response, we drop all its offers AND its TMPX chunks. A hostile-or-flaky // identity endpoint therefore cannot convert identity-gated packages into // unconditionally-served packages, and cannot inject a TMPX token into the // bid response by failing partway through. @@ -329,17 +338,23 @@ func (m *Module) mergeSegments(results []providerResult) []string { } } - // Identity TMPX macros. Names are already provider-namespaced by the - // provider's registered tmpx_macros list (see adcp-go - // tmproto/types_gen.go ProviderRegistration.TmpxMacros), so no key - // transformation here — pass Name=Value verbatim to the ad server. - if r.Identity != nil { - for _, tm := range r.Identity.TmpxMacros { - if tm.Name == "" || tm.Value == "" { - continue - } - if !appendSeg(tm.Name + "=" + tm.Value) { - return out + // Identity TMPX chunks. The provider emits (slot_id, value) + // pairs against its registered tmpx_slots; the publisher-owned + // TmpxMacroMapping resolves (provider, slot_id) to the local + // ad-server macro name. Emit macro=value for every chunk with a + // mapping entry; drop this provider's chunks atomically when + // any chunk's slot is unmapped (fail-closed per adcp + // publisher-tmpx-config.json). + if r.Identity != nil && len(r.Identity.TmpxChunks) > 0 { + providerMap := m.cfg.TmpxMacroMapping[r.Name] + pairs, ok := resolveTmpxChunks(providerMap, r.Identity.TmpxChunks) + if !ok { + logger.Warnf("adcontextprotocol.tmp: dropping provider %q tmpx_chunks: mapping is missing an entry for one or more slot_ids", r.Name) + } else { + for _, kv := range pairs { + if !appendSeg(kv) { + return out + } } } } @@ -353,6 +368,28 @@ func (m *Module) mergeSegments(results []providerResult) []string { return out } +// resolveTmpxChunks maps a provider's ordered chunk sequence to +// publisher-local "macro=value" segments via the publisher's +// (slot_id → macro_name) table for that provider. Any chunk whose +// slot_id is not mapped fails the whole provider closed — the provider's +// chunks are dropped atomically. Empty values are skipped without +// tripping the fail-closed path. Returns (pairs, ok=false) when any +// slot is unmapped so the caller can log and drop. +func resolveTmpxChunks(providerMap map[string]string, chunks []tmproto.TmpxChunk) ([]string, bool) { + pairs := make([]string, 0, len(chunks)) + for _, ch := range chunks { + if ch.Value == "" { + continue + } + macro, ok := providerMap[ch.SlotID] + if !ok || macro == "" { + return nil, false + } + pairs = append(pairs, macro+"="+ch.Value) + } + return pairs, true +} + // stringifySignal accepts scalar signal values (string, bool, number) // and rejects non-scalars — a map or slice from a hostile provider // would flow into targeting as "map[…]" garbage otherwise. @@ -380,7 +417,7 @@ func boundedSegment(s string, maxLen int) string { return s[:maxLen] } -func eligibilitySet(idResp *tmproto.IdentityMatchResponse) map[string]bool { +func eligibilitySet(idResp *tmproto.ProviderIdentityMatchResponse) map[string]bool { if idResp == nil { return nil } diff --git a/modules/adcontextprotocol/tmp/router_test.go b/modules/adcontextprotocol/tmp/router_test.go index b137e2fddce..515189111ce 100644 --- a/modules/adcontextprotocol/tmp/router_test.go +++ b/modules/adcontextprotocol/tmp/router_test.go @@ -57,7 +57,7 @@ func newFixture(t *testing.T) *tmpFixture { f.IdentHandler(w, r) return } - _ = json.NewEncoder(w).Encode(tmproto.IdentityMatchResponse{ + _ = json.NewEncoder(w).Encode(tmproto.ProviderIdentityMatchResponse{ Type: "identity_match_response", RequestID: "req", EligiblePackageIDs: []string{"pkg-a"}, @@ -329,7 +329,7 @@ func TestFanOut_RandomizesContextIdentityOrder(t *testing.T) { } f.IdentHandler = func(w http.ResponseWriter, _ *http.Request) { setFirst("identity") - _ = json.NewEncoder(w).Encode(tmproto.IdentityMatchResponse{Type: "identity_match_response", EligiblePackageIDs: []string{"pkg"}}) + _ = json.NewEncoder(w).Encode(tmproto.ProviderIdentityMatchResponse{Type: "identity_match_response", EligiblePackageIDs: []string{"pkg"}}) } const iterations = 40 @@ -381,7 +381,7 @@ func TestFanOut_SigningHeadersOnOutbound(t *testing.T) { _ = json.NewEncoder(w).Encode(tmproto.ContextMatchResponse{Type: "context_match_response", Offers: []tmproto.Offer{{PackageID: "pkg"}}}) } f.IdentHandler = func(w http.ResponseWriter, r *http.Request) { - _ = json.NewEncoder(w).Encode(tmproto.IdentityMatchResponse{Type: "identity_match_response", EligiblePackageIDs: []string{"pkg"}}) + _ = json.NewEncoder(w).Encode(tmproto.ProviderIdentityMatchResponse{Type: "identity_match_response", EligiblePackageIDs: []string{"pkg"}}) } _ = f.Module.fanOut(context.Background(), deriveInputs(&f.Module.cfg, sampleBidRequest())) @@ -396,13 +396,16 @@ func TestFanOut_SigningHeadersOnOutbound(t *testing.T) { // TestMergeSegments_TMPXAndOfferMacros verifies the four spec surfaces the // module surfaces onto prebid targeting: package IDs (comma-joined under // the configurable single key), per-offer creative macros, response-level -// context signals, and identity TMPX macros — each with its raw key intact, -// no provider-name prefix. +// context signals, and identity TMPX chunks resolved through the +// publisher-owned TmpxMacroMapping (provider→slot_id→ad-server macro). func TestMergeSegments_TMPXAndOfferMacros(t *testing.T) { m := &Module{cfg: Config{ PackageTargetingKey: "adcp_package_id", MaxSegments: 64, MaxSegmentValueLen: 256, + TmpxMacroMapping: map[string]map[string]string{ + "prov": {"primary": "TMPX_1"}, + }, }} results := []providerResult{{ Name: "prov", @@ -413,10 +416,10 @@ func TestMergeSegments_TMPXAndOfferMacros(t *testing.T) { }, Signals: map[string]any{"iab_cat": "sports"}, }, - Identity: &tmproto.IdentityMatchResponse{ + Identity: &tmproto.ProviderIdentityMatchResponse{ EligiblePackageIDs: []string{"pkg-a", "pkg-b"}, - TmpxMacros: []tmproto.TmpxMacro{ - {Name: "SCOPE3_TMPX_1", Value: "opaque-chunk-1"}, + TmpxChunks: []tmproto.TmpxChunk{ + {SlotID: "primary", Value: "opaque-chunk-1"}, }, }, }} @@ -425,7 +428,7 @@ func TestMergeSegments_TMPXAndOfferMacros(t *testing.T) { want := map[string]string{ "brand": "Acme", "iab_cat": "sports", - "SCOPE3_TMPX_1": "opaque-chunk-1", + "TMPX_1": "opaque-chunk-1", "adcp_package_id": "pkg-a,pkg-b", } for _, s := range out { @@ -491,7 +494,7 @@ func TestMergeSegments_EmptyPackageKeyDisables(t *testing.T) { } // TestMergeSegments_FailClosedDropsTMPX confirms the fail-closed path also -// suppresses TMPX macros. If the identity call errored the module has no +// suppresses TMPX chunks. If the identity call errored the module has no // way to know if the token is authorized for the request, so the safe // answer is to drop it — otherwise a flaky identity endpoint could leak // a token onto an impression the eligibility gate would have blocked. @@ -512,3 +515,77 @@ func TestMergeSegments_FailClosedDropsTMPX(t *testing.T) { t.Errorf("expected empty segments on identity-attempted-but-failed; got %v", out) } } + +// TestMergeSegments_TMPXUnmappedSlotDropsProvider verifies the +// publisher-tmpx-config.json fail-closed rule: a chunk whose (provider, +// slot_id) is not present in TmpxMacroMapping causes the whole provider's +// chunks to be dropped atomically. Package IDs and other targeting are +// unaffected — the fail-closed scope is TMPX only. +func TestMergeSegments_TMPXUnmappedSlotDropsProvider(t *testing.T) { + m := &Module{cfg: Config{ + PackageTargetingKey: "adcp_package_id", + MaxSegments: 64, + MaxSegmentValueLen: 256, + TmpxMacroMapping: map[string]map[string]string{ + "prov": {"primary": "TMPX_1"}, + }, + }} + results := []providerResult{{ + Name: "prov", + Context: &tmproto.ContextMatchResponse{ + Offers: []tmproto.Offer{{PackageID: "pkg-a"}}, + }, + Identity: &tmproto.ProviderIdentityMatchResponse{ + EligiblePackageIDs: []string{"pkg-a"}, + TmpxChunks: []tmproto.TmpxChunk{ + {SlotID: "primary", Value: "v1"}, + {SlotID: "secondary", Value: "v2"}, + }, + }, + }} + out := m.mergeSegments(results) + for _, s := range out { + if strings.HasPrefix(s, "TMPX_") { + t.Errorf("expected all TMPX chunks dropped when any slot is unmapped; got %q", s) + } + } + var pkgSeg string + for _, s := range out { + if strings.HasPrefix(s, "adcp_package_id=") { + pkgSeg = s + } + } + if pkgSeg == "" { + t.Errorf("package targeting should still emit; got %v", out) + } +} + +// TestMergeSegments_TMPXProviderNotInMappingSkipped verifies a provider +// absent from TmpxMacroMapping emits no TMPX targeting (the mapping is +// per-surface; a newly onboarded provider not yet trafficked here is the +// same case as an unmapped slot). +func TestMergeSegments_TMPXProviderNotInMappingSkipped(t *testing.T) { + m := &Module{cfg: Config{ + PackageTargetingKey: "adcp_package_id", + MaxSegments: 64, + MaxSegmentValueLen: 256, + }} + results := []providerResult{{ + Name: "prov", + Context: &tmproto.ContextMatchResponse{ + Offers: []tmproto.Offer{{PackageID: "pkg-a"}}, + }, + Identity: &tmproto.ProviderIdentityMatchResponse{ + EligiblePackageIDs: []string{"pkg-a"}, + TmpxChunks: []tmproto.TmpxChunk{ + {SlotID: "primary", Value: "v1"}, + }, + }, + }} + out := m.mergeSegments(results) + for _, s := range out { + if strings.HasPrefix(s, "TMPX_") || strings.HasPrefix(s, "primary=") { + t.Errorf("provider with no mapping entry should emit no TMPX targeting; got %q", s) + } + } +} From fecdb3bbf15d166c752d720f785debf3ccf46655 Mon Sep 17 00:00:00 2001 From: Oleksandr Halushchak Date: Thu, 6 Aug 2026 12:35:01 +0200 Subject: [PATCH 07/11] Enforce ordered-prefix slot contract; reject router-hop fields on provider hop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the publisher-owned TMPX mapping commit. Addresses the gaps a spec-conformance review turned up against adcp publisher-tmpx-config.json, provider-identity-match-response.json, provider-registration.json, tmpx-chunk.json, and docs/trusted-match/router-architecture.mdx. - providers[].tmpx_slots: mirror the provider's registered `tmpx_slots` in module config. mergeSegments now enforces the adcp#5971 ordered-prefix invariant on each provider's emitted TmpxChunks — any deviation (empty, reordered, sparse, unregistered, over-cap, duplicate) drops that provider's chunks atomically. Mirrors adcp-go router/slot_contract.go so the module cannot fall out of sync. - Provider-hop response: reject responses that carry any router-hop or envelope-extension field (tmpx_providers, tmpx, tmpx_values, tmpx_macros, context, ext) — provider-identity-match-response.json encodes that as a `not: {anyOf: [...]}` MUST. - resolveTmpxChunks: an empty chunk value now fails the whole provider closed rather than silently skipping the entry (tmpx-chunk.json marks value required with minLength 1). - providerNameRE: widened to the adcp provider_id charset (`^[A-Za-z0-9_]{1,64}$`) — the previous hyphen-inclusive regex drifted from the spec. - tmpx_macro_mapping validation: outer key must match the provider_id charset, inner key must match the adcp slot_id charset, entries are cross-checked against the provider's registered tmpx_slots (unknown slot rejected; missing coverage warns at startup so the operator can add it before the fail-closed rule catches it at serve time). Value length and slot-count caps mirror publisher-tmpx-config.json. - README + config-doc drift: uses `provider_id` consistently, documents tmpx_slots and the fail-closed rule. Test coverage: enforceProviderSlotContract cases mirror adcp-go's reference table; merge-layer regressions for the reorder / empty-value paths; provider-client test for the forbidden-router-hop-field rejection; config-validation coverage for every new rule. --- modules/adcontextprotocol/tmp/README.md | 36 +++-- modules/adcontextprotocol/tmp/config.go | 115 ++++++++++++-- modules/adcontextprotocol/tmp/config_test.go | 58 ++++++- .../adcontextprotocol/tmp/provider_client.go | 38 +++++ modules/adcontextprotocol/tmp/router.go | 99 +++++++++--- modules/adcontextprotocol/tmp/router_test.go | 142 ++++++++++++++++++ 6 files changed, 446 insertions(+), 42 deletions(-) diff --git a/modules/adcontextprotocol/tmp/README.md b/modules/adcontextprotocol/tmp/README.md index c05ffad83f1..38d07206f9d 100644 --- a/modules/adcontextprotocol/tmp/README.md +++ b/modules/adcontextprotocol/tmp/README.md @@ -43,16 +43,29 @@ hooks: identity_url: https://tmp.example.com/identity context_url: https://tmp.example.com/context timeout_ms: 200 + # `tmpx_slots` mirrors the provider's registered + # `tmpx_slots` list from adcp provider-registration.json. + # Order is significant: the module enforces the ordered- + # prefix invariant (adcp#5971) on incoming responses — + # any provider whose emitted `tmpx_chunks[].slot_id` + # sequence deviates (reordered, sparse, unregistered, + # over-cap) has its chunks dropped atomically. Required + # only when the provider emits TMPX. Capped at 2 per + # adcp v1. + tmpx_slots: + - primary + - secondary # Publisher-owned deployment configuration that resolves each - # provider's ordered TMPX chunks (provider-local {slot_id, value} - # pairs, per adcp publisher-tmpx-config.json) to local ad-server - # macro names on this Prebid Server surface. Outer key is the - # provider's `name` above; inner key is the provider-local - # `slot_id` the provider registered in `tmpx_slots`; value is the - # publisher-local destination (GAM key, VAST URL macro, DOOH - # play-log field). Providers absent from this map emit no TMPX - # targeting. Chunks with an unmapped slot cause the whole - # provider's chunks to be dropped for that impression + # provider's ordered TMPX chunks (provider-local {slot_id, + # value} pairs, per adcp publisher-tmpx-config.json) to local + # ad-server macro names on this Prebid Server surface. Outer + # key MUST match one of the `providers[].name` above (used as + # `provider_id` in the adcp spec); inner key MUST be a + # `slot_id` the provider declared in `tmpx_slots`; value is + # the publisher-local destination (GAM key, VAST URL macro, + # DOOH play-log field). Providers absent from this map emit + # no TMPX targeting. Chunks with an unmapped slot cause the + # whole provider's chunks to be dropped for that impression # (fail-closed). tmpx_macro_mapping: example: @@ -118,9 +131,10 @@ hooks: | `signing.key_id` | Sent in `X-AdCP-Key-Id`. Verifiers use it to look up the matching Ed25519 public key. | | `signing.private_key_pem` | PEM-encoded PKCS#8 Ed25519 private key. | | `property_registry.endpoint` | Resolves `site.domain` / `app.bundle` → `property_rid` via a `GET ?domain=…` call. | -| `providers[].name` | Stable provider identifier. Appears verbatim in logs, metrics, and as the outer key of `tmpx_macro_mapping`. Charset: `[a-z0-9][a-z0-9_-]{0,31}`. | +| `providers[].name` | Stable provider identifier (adcp `provider_id`). Appears verbatim in logs, metrics, and as the outer key of `tmpx_macro_mapping`. Charset matches the adcp spec: `^[A-Za-z0-9_]{1,64}$`. | | `providers[].identity_url` or `providers[].context_url` | At least one is required per provider. | -| `tmpx_macro_mapping` | Optional. Publisher-owned map of `provider_name → slot_id → ad-server macro name` used to route each provider's TMPX chunks. Omit to disable TMPX targeting. | +| `providers[].tmpx_slots` | Optional. Ordered list of `slot_id`s the provider registered in adcp `provider-registration.json`. Required when the provider emits TMPX. The module drops any provider response whose emitted slot sequence is not a non-empty ordered prefix of this list. | +| `tmpx_macro_mapping` | Optional. Publisher-owned map of `provider_id → slot_id → ad-server macro name` used to route each provider's TMPX chunks. Omit to disable TMPX targeting. Missing entries for a provider's registered slots produce a startup warning; unmapped slots seen at serve time fail closed. | ### Providers diff --git a/modules/adcontextprotocol/tmp/config.go b/modules/adcontextprotocol/tmp/config.go index 05afc460293..f959af769fe 100644 --- a/modules/adcontextprotocol/tmp/config.go +++ b/modules/adcontextprotocol/tmp/config.go @@ -7,6 +7,7 @@ import ( "regexp" "github.com/adcontextprotocol/adcp-go/tmproto" + "github.com/prebid/prebid-server/v4/logger" ) // Config is the JSON configuration for the module. See README.md. @@ -155,6 +156,20 @@ type ProviderConfig struct { ContextURL string `json:"context_url"` // TimeoutMs overrides the module-level timeout for this provider. Optional. TimeoutMs int `json:"timeout_ms"` + + // TmpxSlots is the ordered list of provider-local slot IDs this + // provider registered in its `tmpx_slots` field per adcp + // provider-registration.json. The module uses it to enforce the + // ordered-prefix invariant on incoming responses (adcp#5971): a + // provider's emitted tmpx_chunks[].slot_id sequence MUST be a + // non-empty ordered prefix of this list; any other sequence + // (reordered, sparse, unregistered, over-cap) causes the whole + // provider's chunks to be dropped atomically for that impression. + // + // Required only when the provider will emit TMPX. Providers that do + // not populate tmpx_chunks omit this list. Cap of 2 slots in v1 + // mirrors the schema's maxItems=2. + TmpxSlots []string `json:"tmpx_slots"` } // MaskingConfig mirrors the categories the previous RTD module exposed, so @@ -181,13 +196,29 @@ type DeviceMaskingConfig struct { PreserveMobileIds bool `json:"preserve_mobile_ids"` } -// providerNameRE constrains provider names to a subset of the adcp -// provider_id charset (alphanumeric + underscore, up to 64 chars per the -// spec) so the name can appear verbatim in logs, metrics, and the outer -// key of TmpxMacroMapping without quoting or normalization mismatches. -// Restricted to lower-case for operational uniformity within a single -// deployment. -var providerNameRE = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,31}$`) +// providerNameRE matches the adcp `provider_id` charset from +// provider-registration.json (`^[A-Za-z0-9_]+$`, 1–64 chars). The name +// appears verbatim in logs, metrics, and as the outer key of +// TmpxMacroMapping — matching the spec charset avoids quoting or +// normalization mismatches when the same identifier flows across +// registration and mapping. +var providerNameRE = regexp.MustCompile(`^[A-Za-z0-9_]{1,64}$`) + +// tmpxSlotIDRE matches the adcp `slot_id` charset from tmpx-chunk.json +// (`^[a-zA-Z][a-zA-Z0-9_]*$`, 1–64 chars). Applied to both provider +// TmpxSlots entries and TmpxMacroMapping inner keys. +var tmpxSlotIDRE = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_]*$`) + +// tmpxMaxSlots mirrors the v1 slot cap from provider-registration.json +// and tmpx-chunk-derived response schemas. +const tmpxMaxSlots = 2 + +// tmpxMaxSlotIDLen mirrors the schema's `slot_id` maxLength. +const tmpxMaxSlotIDLen = 64 + +// tmpxMaxMacroLen mirrors publisher-tmpx-config.json's inner-value +// maxLength for ad-server destination strings. +const tmpxMaxMacroLen = 128 // validated returns a Config with defaults filled in, along with the parsed // Ed25519 private key. Invalid configuration is rejected here rather than at @@ -216,7 +247,7 @@ func (c *Config) validated() (ed25519.PrivateKey, error) { return nil, fmt.Errorf("providers[%d].name is required", i) } if !providerNameRE.MatchString(p.Name) { - return nil, fmt.Errorf("providers[%d].name %q must match %s (lowercase letters, digits, underscore, hyphen; up to 32 chars)", i, p.Name, providerNameRE) + return nil, fmt.Errorf("providers[%d].name %q must match adcp provider_id charset %s", i, p.Name, providerNameRE) } if seenNames[p.Name] { return nil, fmt.Errorf("providers[%d].name %q is duplicated", i, p.Name) @@ -225,6 +256,25 @@ func (c *Config) validated() (ed25519.PrivateKey, error) { if p.IdentityURL == "" && p.ContextURL == "" { return nil, fmt.Errorf("providers[%d] (%s): at least one of identity_url or context_url is required", i, p.Name) } + if len(p.TmpxSlots) > tmpxMaxSlots { + return nil, fmt.Errorf("providers[%d] (%s): tmpx_slots holds %d entries; adcp v1 caps registered slots at %d", i, p.Name, len(p.TmpxSlots), tmpxMaxSlots) + } + seenSlots := make(map[string]bool, len(p.TmpxSlots)) + for j, slotID := range p.TmpxSlots { + if slotID == "" { + return nil, fmt.Errorf("providers[%d] (%s): tmpx_slots[%d] must be non-empty", i, p.Name, j) + } + if len(slotID) > tmpxMaxSlotIDLen { + return nil, fmt.Errorf("providers[%d] (%s): tmpx_slots[%d] %q exceeds %d chars", i, p.Name, j, slotID, tmpxMaxSlotIDLen) + } + if !tmpxSlotIDRE.MatchString(slotID) { + return nil, fmt.Errorf("providers[%d] (%s): tmpx_slots[%d] %q must match adcp slot_id charset %s", i, p.Name, j, slotID, tmpxSlotIDRE) + } + if seenSlots[slotID] { + return nil, fmt.Errorf("providers[%d] (%s): tmpx_slots[%d] %q is duplicated (schema requires uniqueItems)", i, p.Name, j, slotID) + } + seenSlots[slotID] = true + } } if c.PropertyRegistry.Endpoint == "" { return nil, errors.New("property_registry.endpoint is required") @@ -274,21 +324,66 @@ func (c *Config) validated() (ed25519.PrivateKey, error) { c.Masking.User.PreserveEids = []string{"liveramp.com", "uidapi.com", "id5-sync.com"} } } + providerSlots := make(map[string]map[string]bool, len(c.Providers)) + for i := range c.Providers { + p := &c.Providers[i] + set := make(map[string]bool, len(p.TmpxSlots)) + for _, s := range p.TmpxSlots { + set[s] = true + } + providerSlots[p.Name] = set + } for providerID, slotMap := range c.TmpxMacroMapping { + if !providerNameRE.MatchString(providerID) { + return nil, fmt.Errorf("tmpx_macro_mapping key %q must match adcp provider_id charset %s", providerID, providerNameRE) + } if !seenNames[providerID] { return nil, fmt.Errorf("tmpx_macro_mapping refers to provider %q that is not in providers[]", providerID) } if len(slotMap) == 0 { return nil, fmt.Errorf("tmpx_macro_mapping[%q] is empty; omit the provider to disable its TMPX targeting", providerID) } + if len(slotMap) > tmpxMaxSlots { + return nil, fmt.Errorf("tmpx_macro_mapping[%q] holds %d entries; adcp v1 caps slots at %d", providerID, len(slotMap), tmpxMaxSlots) + } + registered := providerSlots[providerID] for slotID, macro := range slotMap { - if slotID == "" { - return nil, fmt.Errorf("tmpx_macro_mapping[%q]: slot_id must be non-empty", providerID) + if !tmpxSlotIDRE.MatchString(slotID) { + return nil, fmt.Errorf("tmpx_macro_mapping[%q]: slot_id %q must match adcp slot_id charset %s", providerID, slotID, tmpxSlotIDRE) + } + if len(slotID) > tmpxMaxSlotIDLen { + return nil, fmt.Errorf("tmpx_macro_mapping[%q]: slot_id %q exceeds %d chars", providerID, slotID, tmpxMaxSlotIDLen) } if macro == "" { return nil, fmt.Errorf("tmpx_macro_mapping[%q][%q]: destination macro must be non-empty", providerID, slotID) } + if len(macro) > tmpxMaxMacroLen { + return nil, fmt.Errorf("tmpx_macro_mapping[%q][%q]: destination macro exceeds %d chars", providerID, slotID, tmpxMaxMacroLen) + } + if len(registered) > 0 && !registered[slotID] { + return nil, fmt.Errorf("tmpx_macro_mapping[%q][%q] references a slot_id not in provider %q tmpx_slots %v", providerID, slotID, providerID, c.providerSlotList(providerID)) + } + } + if len(registered) > 0 { + for slotID := range registered { + if _, ok := slotMap[slotID]; !ok { + logger.Warnf("adcontextprotocol.tmp: tmpx_macro_mapping[%q] has no entry for registered slot_id %q; that slot will fail closed at serve time", providerID, slotID) + } + } } } return priv, nil } + +// providerSlotList returns the ordered tmpx_slots list for a provider by +// name, or nil when the provider is not configured. Used only for error +// messages so operators see the registered list next to the offending +// mapping entry. +func (c *Config) providerSlotList(name string) []string { + for i := range c.Providers { + if c.Providers[i].Name == name { + return c.Providers[i].TmpxSlots + } + } + return nil +} diff --git a/modules/adcontextprotocol/tmp/config_test.go b/modules/adcontextprotocol/tmp/config_test.go index 1170ebb984d..24c43fb6888 100644 --- a/modules/adcontextprotocol/tmp/config_test.go +++ b/modules/adcontextprotocol/tmp/config_test.go @@ -117,17 +117,71 @@ func TestValidated_MaskingDefaultEIDList(t *testing.T) { func TestValidated_TmpxMacroMappingRejectsUnknownProvider(t *testing.T) { cfg := validConfig(t) cfg.TmpxMacroMapping = map[string]map[string]string{ - "not-a-configured-provider": {"primary": "TMPX_1"}, + "other_provider": {"primary": "TMPX_1"}, } _, err := cfg.validated() if err == nil { t.Fatal("expected error when tmpx_macro_mapping references an unknown provider") } - if !strings.Contains(err.Error(), "not-a-configured-provider") { + if !strings.Contains(err.Error(), "other_provider") { t.Errorf("expected error to name the offending provider; got %v", err) } } +func TestValidated_ProviderNameRejectsSpecCharsetViolation(t *testing.T) { + cfg := validConfig(t) + cfg.Providers[0].Name = "has-a-hyphen" + _, err := cfg.validated() + if err == nil { + t.Fatal("expected error when provider name uses a char outside the adcp provider_id charset") + } +} + +func TestValidated_TmpxSlotsOverV1Cap(t *testing.T) { + cfg := validConfig(t) + cfg.Providers[0].TmpxSlots = []string{"a", "b", "c"} + if _, err := cfg.validated(); err == nil { + t.Fatal("expected error when tmpx_slots exceeds v1 cap") + } +} + +func TestValidated_TmpxSlotIDCharsetEnforced(t *testing.T) { + cfg := validConfig(t) + cfg.Providers[0].TmpxSlots = []string{"1_leading_digit"} + if _, err := cfg.validated(); err == nil { + t.Fatal("expected error when a slot_id fails the adcp charset") + } +} + +func TestValidated_TmpxSlotsRejectDuplicates(t *testing.T) { + cfg := validConfig(t) + cfg.Providers[0].TmpxSlots = []string{"primary", "primary"} + if _, err := cfg.validated(); err == nil { + t.Fatal("expected error when tmpx_slots has duplicates") + } +} + +func TestValidated_TmpxMacroMappingRejectsSlotNotInRegistration(t *testing.T) { + cfg := validConfig(t) + cfg.Providers[0].TmpxSlots = []string{"primary"} + cfg.TmpxMacroMapping = map[string]map[string]string{ + "example": {"unregistered": "TMPX_X"}, + } + if _, err := cfg.validated(); err == nil { + t.Fatal("expected error when mapping references a slot_id the provider did not register") + } +} + +func TestValidated_TmpxMacroMappingSlotIDCharsetEnforced(t *testing.T) { + cfg := validConfig(t) + cfg.TmpxMacroMapping = map[string]map[string]string{ + "example": {"1_bad_slot": "TMPX_1"}, + } + if _, err := cfg.validated(); err == nil { + t.Fatal("expected error when mapping slot_id fails the adcp charset") + } +} + func TestValidated_TmpxMacroMappingRejectsEmptyMacro(t *testing.T) { cfg := validConfig(t) cfg.TmpxMacroMapping = map[string]map[string]string{ diff --git a/modules/adcontextprotocol/tmp/provider_client.go b/modules/adcontextprotocol/tmp/provider_client.go index 707b0a65b64..b62f5dfa9fc 100644 --- a/modules/adcontextprotocol/tmp/provider_client.go +++ b/modules/adcontextprotocol/tmp/provider_client.go @@ -3,6 +3,7 @@ package tmp import ( "bytes" "context" + "encoding/json" "fmt" "io" "net/http" @@ -58,6 +59,9 @@ func (m *Module) callIdentity(ctx context.Context, p ProviderConfig, req *tmprot if err != nil { return nil, err } + if forbidden := findForbiddenProviderResponseFields(body); forbidden != "" { + return nil, fmt.Errorf("identity response from %s carries forbidden provider-hop field %q; adcp provider-identity-match-response.json rejects router-hop and envelope-extension fields", p.Name, forbidden) + } var resp tmproto.ProviderIdentityMatchResponse if err := jsonutil.Unmarshal(body, &resp); err != nil { return nil, fmt.Errorf("identity decode: %w", err) @@ -65,6 +69,40 @@ func (m *Module) callIdentity(ctx context.Context, p ProviderConfig, req *tmprot return &resp, nil } +// forbiddenProviderResponseFields enumerates the top-level fields the +// provider→router shape MUST NOT carry per adcp +// provider-identity-match-response.json's `not: {anyOf: [...]}` clause. +// The router (this module) MUST reject any provider response that carries +// any of these — they belong on the router→publisher hop only, or, for +// `context` / `ext`, are explicitly forbidden to prevent envelope-level +// data leakage across the identity privacy boundary. +var forbiddenProviderResponseFields = []string{ + "tmpx_providers", + "tmpx", + "tmpx_values", + "tmpx_macros", + "context", + "ext", +} + +// findForbiddenProviderResponseFields returns the first top-level field +// name present in the raw JSON body that the provider→router response +// schema forbids, or "" when the body carries none. Uses a minimal +// map[string]json.RawMessage decode so it costs a single pass and does +// not require touching the strongly-typed response struct. +func findForbiddenProviderResponseFields(body []byte) string { + var top map[string]json.RawMessage + if err := jsonutil.Unmarshal(body, &top); err != nil { + return "" + } + for _, f := range forbiddenProviderResponseFields { + if _, ok := top[f]; ok { + return f + } + } + return "" +} + // doTMP sends a signed TMP request and returns the raw response body. A // non-2xx response is surfaced as an error containing the provider's error // envelope when parseable, so callers can distinguish "unknown package" from diff --git a/modules/adcontextprotocol/tmp/router.go b/modules/adcontextprotocol/tmp/router.go index eaefe1f9537..58512a0b9ce 100644 --- a/modules/adcontextprotocol/tmp/router.go +++ b/modules/adcontextprotocol/tmp/router.go @@ -338,22 +338,39 @@ func (m *Module) mergeSegments(results []providerResult) []string { } } - // Identity TMPX chunks. The provider emits (slot_id, value) - // pairs against its registered tmpx_slots; the publisher-owned - // TmpxMacroMapping resolves (provider, slot_id) to the local - // ad-server macro name. Emit macro=value for every chunk with a - // mapping entry; drop this provider's chunks atomically when - // any chunk's slot is unmapped (fail-closed per adcp - // publisher-tmpx-config.json). + // Identity TMPX chunks. Two spec MUSTs apply here: + // + // 1. Ordered-prefix slot contract (adcp#5971, + // provider-identity-match-response.json): the provider's + // emitted slot_id sequence MUST be a non-empty ordered + // prefix of its registered tmpx_slots list. Any deviation + // (empty, reordered, sparse, unregistered, over-cap, or + // duplicate) drops the provider's chunks atomically. + // + // 2. Publisher-owned destination mapping + // (publisher-tmpx-config.json): each surviving chunk's + // (provider, slot_id) MUST resolve through TmpxMacroMapping + // to a publisher-local ad-server destination. Any unmapped + // slot fails the whole provider closed on this impression. + // + // Both are enforced together: if either check fails, the whole + // provider's chunks are dropped. Other providers on the same + // response are unaffected. if r.Identity != nil && len(r.Identity.TmpxChunks) > 0 { - providerMap := m.cfg.TmpxMacroMapping[r.Name] - pairs, ok := resolveTmpxChunks(providerMap, r.Identity.TmpxChunks) - if !ok { - logger.Warnf("adcontextprotocol.tmp: dropping provider %q tmpx_chunks: mapping is missing an entry for one or more slot_ids", r.Name) + registered := m.providerTmpxSlots(r.Name) + if !enforceProviderSlotContract(registered, r.Identity.TmpxChunks) { + emitted := chunkSlotIDs(r.Identity.TmpxChunks) + logger.Warnf("adcontextprotocol.tmp: dropping provider %q tmpx_chunks: emitted slot sequence %v is not an ordered prefix of registered slots %v", r.Name, emitted, registered) } else { - for _, kv := range pairs { - if !appendSeg(kv) { - return out + providerMap := m.cfg.TmpxMacroMapping[r.Name] + pairs, ok := resolveTmpxChunks(providerMap, r.Identity.TmpxChunks) + if !ok { + logger.Warnf("adcontextprotocol.tmp: dropping provider %q tmpx_chunks: publisher tmpx_macro_mapping is missing an entry for one or more slot_ids or a chunk carried an empty value", r.Name) + } else { + for _, kv := range pairs { + if !appendSeg(kv) { + return out + } } } } @@ -371,15 +388,16 @@ func (m *Module) mergeSegments(results []providerResult) []string { // resolveTmpxChunks maps a provider's ordered chunk sequence to // publisher-local "macro=value" segments via the publisher's // (slot_id → macro_name) table for that provider. Any chunk whose -// slot_id is not mapped fails the whole provider closed — the provider's -// chunks are dropped atomically. Empty values are skipped without -// tripping the fail-closed path. Returns (pairs, ok=false) when any -// slot is unmapped so the caller can log and drop. +// slot_id has no mapping entry, or whose value is empty +// (tmpx-chunk.json marks value required with minLength 1), fails the +// whole provider closed — the provider's chunks are dropped atomically +// per publisher-tmpx-config.json. Returns (pairs, ok=false) so the +// caller logs once and drops. func resolveTmpxChunks(providerMap map[string]string, chunks []tmproto.TmpxChunk) ([]string, bool) { pairs := make([]string, 0, len(chunks)) for _, ch := range chunks { if ch.Value == "" { - continue + return nil, false } macro, ok := providerMap[ch.SlotID] if !ok || macro == "" { @@ -390,6 +408,49 @@ func resolveTmpxChunks(providerMap map[string]string, chunks []tmproto.TmpxChunk return pairs, true } +// enforceProviderSlotContract mirrors adcp-go router/slot_contract.go and +// implements the router MUST from adcp#5971: a provider's emitted +// tmpx_chunks[].slot_id sequence must be a non-empty ordered prefix of +// that provider's registered tmpx_slots. Any other sequence (empty, +// reordered, sparse, unregistered, longer than registered, duplicate) +// returns false so the caller drops the provider's chunks atomically. +func enforceProviderSlotContract(registered []string, chunks []tmproto.TmpxChunk) bool { + if len(chunks) == 0 || len(chunks) > len(registered) { + return false + } + for i, c := range chunks { + if registered[i] != c.SlotID { + return false + } + } + return true +} + +// providerTmpxSlots returns the registered tmpx_slots list for the named +// provider, or nil when the provider is not configured. Nil forces the +// slot-contract check to fail — a provider that has emitted chunks +// without any registered slots cannot satisfy the ordered-prefix +// invariant. +func (m *Module) providerTmpxSlots(name string) []string { + for i := range m.cfg.Providers { + if m.cfg.Providers[i].Name == name { + return m.cfg.Providers[i].TmpxSlots + } + } + return nil +} + +// chunkSlotIDs extracts the emitted slot_id sequence for diagnostic +// logging. Kept separate from enforceProviderSlotContract so the hot +// path does not allocate when the contract holds. +func chunkSlotIDs(chunks []tmproto.TmpxChunk) []string { + out := make([]string, len(chunks)) + for i, c := range chunks { + out[i] = c.SlotID + } + return out +} + // stringifySignal accepts scalar signal values (string, bool, number) // and rejects non-scalars — a map or slice from a hostile provider // would flow into targeting as "map[…]" garbage otherwise. diff --git a/modules/adcontextprotocol/tmp/router_test.go b/modules/adcontextprotocol/tmp/router_test.go index 515189111ce..7c7c5dbe52c 100644 --- a/modules/adcontextprotocol/tmp/router_test.go +++ b/modules/adcontextprotocol/tmp/router_test.go @@ -238,6 +238,36 @@ func TestFanOut_ProviderDecodeErrorSurvives(t *testing.T) { } } +// A provider that returns a well-formed identity response but sneaks in +// a router-hop field (`tmpx_providers`, `tmpx`, etc.) MUST be rejected — +// adcp provider-identity-match-response.json's `not: {anyOf: [...]}` +// clause is a schema-level MUST. The module surfaces this as a +// provider-level error so the auction still completes. +func TestFanOut_RejectsForbiddenRouterHopFieldOnProviderResponse(t *testing.T) { + f := newFixture(t) + defer f.Close() + + f.IdentHandler = func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"type":"identity_match_response","request_id":"r","eligible_package_ids":["pkg-a"],"serve_window_sec":60,"tmpx":"leaked"}`)) + } + + res := f.Module.fanOut(context.Background(), deriveInputs(&f.Module.cfg, sampleBidRequest())) + if res == nil { + t.Fatal("expected non-nil result") + } + if res.ErrCount != 1 { + t.Errorf("expected 1 provider with errors when identity response leaks a router-hop field; got %d", res.ErrCount) + } + // The context call should have succeeded — its offers stay in the merge, + // but eligibility is unknown so identity-attempted fails closed and no + // package segments emit. + for _, s := range res.Segments { + if strings.HasPrefix(s, "adcp_package_id") { + t.Errorf("identity-forbidden-field rejection must fail closed; got package segment %q", s) + } + } +} + // panickingRoundTripper panics inside RoundTrip. The fan-out's inner // goroutine must recover, record the error, and let the sibling call // complete instead of taking the process down. @@ -403,6 +433,9 @@ func TestMergeSegments_TMPXAndOfferMacros(t *testing.T) { PackageTargetingKey: "adcp_package_id", MaxSegments: 64, MaxSegmentValueLen: 256, + Providers: []ProviderConfig{ + {Name: "prov", TmpxSlots: []string{"primary"}}, + }, TmpxMacroMapping: map[string]map[string]string{ "prov": {"primary": "TMPX_1"}, }, @@ -526,6 +559,9 @@ func TestMergeSegments_TMPXUnmappedSlotDropsProvider(t *testing.T) { PackageTargetingKey: "adcp_package_id", MaxSegments: 64, MaxSegmentValueLen: 256, + Providers: []ProviderConfig{ + {Name: "prov", TmpxSlots: []string{"primary", "secondary"}}, + }, TmpxMacroMapping: map[string]map[string]string{ "prov": {"primary": "TMPX_1"}, }, @@ -560,6 +596,109 @@ func TestMergeSegments_TMPXUnmappedSlotDropsProvider(t *testing.T) { } } +// TestEnforceProviderSlotContract covers the ordered-prefix invariant +// from adcp#5971. Mirrors the reference implementation in adcp-go +// router/slot_contract.go so any drift shows up here first. +func TestEnforceProviderSlotContract(t *testing.T) { + slot := func(id string) tmproto.TmpxChunk { return tmproto.TmpxChunk{SlotID: id, Value: "v"} } + cases := []struct { + name string + registered []string + chunks []tmproto.TmpxChunk + want bool + }{ + {"exact-prefix-one", []string{"primary", "secondary"}, []tmproto.TmpxChunk{slot("primary")}, true}, + {"exact-full", []string{"primary", "secondary"}, []tmproto.TmpxChunk{slot("primary"), slot("secondary")}, true}, + {"empty-chunks", []string{"primary"}, nil, false}, + {"no-registration", nil, []tmproto.TmpxChunk{slot("primary")}, false}, + {"reordered", []string{"primary", "secondary"}, []tmproto.TmpxChunk{slot("secondary"), slot("primary")}, false}, + {"sparse", []string{"primary", "secondary"}, []tmproto.TmpxChunk{slot("secondary")}, false}, + {"unregistered", []string{"primary"}, []tmproto.TmpxChunk{slot("other")}, false}, + {"over-cap", []string{"primary"}, []tmproto.TmpxChunk{slot("primary"), slot("primary")}, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := enforceProviderSlotContract(tc.registered, tc.chunks); got != tc.want { + t.Errorf("enforceProviderSlotContract(%v, %v) = %v, want %v", tc.registered, tc.chunks, got, tc.want) + } + }) + } +} + +// TestMergeSegments_TMPXReorderedSlotsDropped verifies the ordered-prefix +// slot contract at the merge layer: even when every emitted slot_id is +// mapped, an out-of-order sequence causes the whole provider's chunks +// to be dropped. This closes the gap where a compromised provider could +// hijack a peer's macro slot by reordering. +func TestMergeSegments_TMPXReorderedSlotsDropped(t *testing.T) { + m := &Module{cfg: Config{ + PackageTargetingKey: "adcp_package_id", + MaxSegments: 64, + MaxSegmentValueLen: 256, + Providers: []ProviderConfig{ + {Name: "prov", TmpxSlots: []string{"primary", "secondary"}}, + }, + TmpxMacroMapping: map[string]map[string]string{ + "prov": {"primary": "TMPX_1", "secondary": "TMPX_2"}, + }, + }} + results := []providerResult{{ + Name: "prov", + Context: &tmproto.ContextMatchResponse{ + Offers: []tmproto.Offer{{PackageID: "pkg-a"}}, + }, + Identity: &tmproto.ProviderIdentityMatchResponse{ + EligiblePackageIDs: []string{"pkg-a"}, + TmpxChunks: []tmproto.TmpxChunk{ + {SlotID: "secondary", Value: "v2"}, + {SlotID: "primary", Value: "v1"}, + }, + }, + }} + out := m.mergeSegments(results) + for _, s := range out { + if strings.HasPrefix(s, "TMPX_") { + t.Errorf("expected TMPX chunks dropped on out-of-order sequence; got %q", s) + } + } +} + +// TestMergeSegments_TMPXEmptyValueFailsClosed verifies that a chunk with +// empty value (tmpx-chunk.json marks value required, minLength 1) causes +// the whole provider's chunks to drop, not just skip that chunk. +func TestMergeSegments_TMPXEmptyValueFailsClosed(t *testing.T) { + m := &Module{cfg: Config{ + PackageTargetingKey: "adcp_package_id", + MaxSegments: 64, + MaxSegmentValueLen: 256, + Providers: []ProviderConfig{ + {Name: "prov", TmpxSlots: []string{"primary", "secondary"}}, + }, + TmpxMacroMapping: map[string]map[string]string{ + "prov": {"primary": "TMPX_1", "secondary": "TMPX_2"}, + }, + }} + results := []providerResult{{ + Name: "prov", + Context: &tmproto.ContextMatchResponse{ + Offers: []tmproto.Offer{{PackageID: "pkg-a"}}, + }, + Identity: &tmproto.ProviderIdentityMatchResponse{ + EligiblePackageIDs: []string{"pkg-a"}, + TmpxChunks: []tmproto.TmpxChunk{ + {SlotID: "primary", Value: "v1"}, + {SlotID: "secondary", Value: ""}, + }, + }, + }} + out := m.mergeSegments(results) + for _, s := range out { + if strings.HasPrefix(s, "TMPX_") { + t.Errorf("expected TMPX chunks dropped when any value is empty; got %q", s) + } + } +} + // TestMergeSegments_TMPXProviderNotInMappingSkipped verifies a provider // absent from TmpxMacroMapping emits no TMPX targeting (the mapping is // per-surface; a newly onboarded provider not yet trafficked here is the @@ -569,6 +708,9 @@ func TestMergeSegments_TMPXProviderNotInMappingSkipped(t *testing.T) { PackageTargetingKey: "adcp_package_id", MaxSegments: 64, MaxSegmentValueLen: 256, + Providers: []ProviderConfig{ + {Name: "prov", TmpxSlots: []string{"primary"}}, + }, }} results := []providerResult{{ Name: "prov", From 0b58e9c573da0794d2699c651458255392ffc470 Mon Sep 17 00:00:00 2001 From: Oleksandr Halushchak Date: Thu, 6 Aug 2026 13:14:02 +0200 Subject: [PATCH 08/11] Property registry: switch to POST /api/registry/resolve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The catalog resolve endpoint on agenticadvertising.org is POST /api/registry/resolve — takes {identifiers, provenance, mode} and returns {resolved[]{property_rid, status, classification, source}} per adcp catalog-openapi.ts. The module was hitting the deprecated GET /api/properties/resolve, which returns a different, un-nested shape that has no property_rid at all — every real deployment would silently short-circuit before calling the TMP agents. - fetch() now POSTs the ResolveRequest body and parses resolved[0].property_rid. null property_rid (excluded / publisher_mask / unresolved-in-lookup) caches as a negative hit. - PropertyRegistryConfig gains Mode ("resolve" | "lookup") and ProvenanceType / ProvenanceContext, matching the ResolveRequest envelope. Defaults: endpoint = agenticadvertising.org, mode = resolve, provenance_type = member_assertion. - Config validation: mode=resolve without an auth_bearer is rejected; provenance_type is checked against the adcp FactProvenance.type enum (crawl is reserved for server-side pipelines and excluded). - Identifier type selection: heuristic picks `bundle` for reverse-DNS shapes (com./io./org./net./app./co./dev.) and `domain` for everything else, matching adcp CatalogIdentifier.type. - Classification → PropertyType mapping fills in the tmproto enum from the catalog's `classification` field when it's a known media type; the generic "property" bucket leaves it empty so the OpenRTB adapter's heuristic wins. - Fixture tests updated to serve the new response envelope and exercise the mode/provenance forwarding path. --- modules/adcontextprotocol/tmp/README.md | 20 +- modules/adcontextprotocol/tmp/config.go | 53 +++++- modules/adcontextprotocol/tmp/config_test.go | 57 +++++- modules/adcontextprotocol/tmp/hooks_test.go | 12 +- .../tmp/property_registry.go | 165 +++++++++++++---- .../tmp/property_registry_test.go | 175 +++++++++++++++--- modules/adcontextprotocol/tmp/router_test.go | 15 +- 7 files changed, 407 insertions(+), 90 deletions(-) diff --git a/modules/adcontextprotocol/tmp/README.md b/modules/adcontextprotocol/tmp/README.md index 38d07206f9d..6226f7fb04c 100644 --- a/modules/adcontextprotocol/tmp/README.md +++ b/modules/adcontextprotocol/tmp/README.md @@ -32,8 +32,20 @@ hooks: # your deployment YAML. private_key_pem: ${ADCP_TMP_SIGNING_KEY_PEM} property_registry: - endpoint: https://agenticadvertising.org/api/properties/resolve - auth_bearer: ${ADCP_REGISTRY_TOKEN} # optional + # POST /api/registry/resolve — adcp catalog-openapi.ts + # ResolveRequest/ResolveResponse. The module sends + # {identifiers:[{type,value}], provenance, mode} and reads + # resolved[0].property_rid. + endpoint: https://agenticadvertising.org/api/registry/resolve + # "resolve" (default) contributes the identifier to the catalog + # and requires auth_bearer. "lookup" is a pure read with no auth + # and returns null property_rid for unknown identifiers. + mode: resolve + auth_bearer: ${ADCP_REGISTRY_TOKEN} + # FactProvenance.type — how the catalog attributes this request. + # See adcp catalog-openapi.ts FactProvenance for the enum. + provenance_type: member_assertion + provenance_context: prebid-server cache_ttl_seconds: 3600 negative_cache_ttl_seconds: 300 cache_size: 4096 @@ -130,7 +142,9 @@ hooks: | `seller_agent_url` | Publicly reachable URL identifying this Prebid Server deployment as a seller agent. Must appear as one of `authorized_agents[].url` in the publisher's `adagents.json` (compared under AdCP URL canonicalization). | | `signing.key_id` | Sent in `X-AdCP-Key-Id`. Verifiers use it to look up the matching Ed25519 public key. | | `signing.private_key_pem` | PEM-encoded PKCS#8 Ed25519 private key. | -| `property_registry.endpoint` | Resolves `site.domain` / `app.bundle` → `property_rid` via a `GET ?domain=…` call. | +| `property_registry.endpoint` | Resolves `site.domain` / `app.bundle` → `property_rid` via `POST /api/registry/resolve` (adcp `ResolveRequest`/`ResolveResponse`). Defaults to `https://agenticadvertising.org/api/registry/resolve` when omitted. | +| `property_registry.mode` | `resolve` (default) contributes to the catalog and requires `auth_bearer`; `lookup` is an unauthenticated pure read. | +| `property_registry.provenance_type` | Enum from adcp `FactProvenance.type`. Default `member_assertion`. `crawl` is reserved for server-side pipelines and rejected. | | `providers[].name` | Stable provider identifier (adcp `provider_id`). Appears verbatim in logs, metrics, and as the outer key of `tmpx_macro_mapping`. Charset matches the adcp spec: `^[A-Za-z0-9_]{1,64}$`. | | `providers[].identity_url` or `providers[].context_url` | At least one is required per provider. | | `providers[].tmpx_slots` | Optional. Ordered list of `slot_id`s the provider registered in adcp `provider-registration.json`. Required when the provider emits TMPX. The module drops any provider response whose emitted slot sequence is not a non-empty ordered prefix of this list. | diff --git a/modules/adcontextprotocol/tmp/config.go b/modules/adcontextprotocol/tmp/config.go index f959af769fe..7ef0b8c5f01 100644 --- a/modules/adcontextprotocol/tmp/config.go +++ b/modules/adcontextprotocol/tmp/config.go @@ -129,13 +129,35 @@ type SigningConfig struct { // PropertyRegistryConfig configures the domain → property_rid resolver. type PropertyRegistryConfig struct { - // Endpoint is the resolve endpoint of the property registry, e.g. - // https://agenticadvertising.org/api/properties/resolve. Domain is - // appended as ?domain=… on GET. + // Endpoint is the POST resolve endpoint of the property catalog, e.g. + // https://agenticadvertising.org/api/registry/resolve. The module sends + // `{identifiers: [{type, value}], provenance: {...}, mode}` and parses + // resolved[].property_rid out of the response. Default: + // https://agenticadvertising.org/api/registry/resolve. Endpoint string `json:"endpoint"` // AuthBearer is the optional bearer token sent as Authorization: Bearer … - // on registry calls. May be substituted from env in deployment YAML. + // on registry calls. Required for Mode="resolve" (contributes and + // creates missing catalog entries); optional for Mode="lookup" (pure + // read). May be substituted from env in deployment YAML. AuthBearer string `json:"auth_bearer"` + // Mode selects the resolve verb: "resolve" (default; requires + // AuthBearer; contributes new identifiers to the catalog) or "lookup" + // (pure read; no auth needed; returns null property_rid for unknown + // identifiers). Publishers running Prebid Server against a shared + // catalog SHOULD use "resolve" so their inventory lights up on the + // catalog even when a domain is new. + Mode string `json:"mode"` + // ProvenanceType is the FactProvenance.type envelope on every request. + // Enum, from adcp catalog-openapi.ts: agency_allowlist, + // publisher_declaration, impression_log, ssp_inventory, deal_history, + // data_partner, member_assertion. `crawl` is reserved for server-side + // pipelines and rejected by the registry. Default: member_assertion — + // a Prebid Server operator resolving on behalf of their own member org. + ProvenanceType string `json:"provenance_type"` + // ProvenanceContext is an optional free-text annotation attached to + // FactProvenance.context (e.g. "prebid-server:staging"). Ignored when + // empty. + ProvenanceContext string `json:"provenance_context"` // CacheTTLSeconds is how long a successful lookup is memoized. Default 3600. CacheTTLSeconds int `json:"cache_ttl_seconds"` // NegativeCacheTTLSeconds is how long a "not found" answer is memoized. Default 300. @@ -277,7 +299,28 @@ func (c *Config) validated() (ed25519.PrivateKey, error) { } } if c.PropertyRegistry.Endpoint == "" { - return nil, errors.New("property_registry.endpoint is required") + c.PropertyRegistry.Endpoint = "https://agenticadvertising.org/api/registry/resolve" + } + switch c.PropertyRegistry.Mode { + case "": + c.PropertyRegistry.Mode = "resolve" + case "resolve", "lookup": + // ok + default: + return nil, fmt.Errorf("property_registry.mode %q must be one of resolve, lookup", c.PropertyRegistry.Mode) + } + if c.PropertyRegistry.Mode == "resolve" && c.PropertyRegistry.AuthBearer == "" { + return nil, errors.New("property_registry.auth_bearer is required when mode is resolve; use mode: lookup for the unauthenticated read") + } + if c.PropertyRegistry.ProvenanceType == "" { + c.PropertyRegistry.ProvenanceType = "member_assertion" + } + switch c.PropertyRegistry.ProvenanceType { + case "agency_allowlist", "publisher_declaration", "impression_log", + "ssp_inventory", "deal_history", "data_partner", "member_assertion": + // ok — enum from adcp catalog-openapi.ts FactProvenance.type + default: + return nil, fmt.Errorf("property_registry.provenance_type %q is not a valid adcp FactProvenance.type", c.PropertyRegistry.ProvenanceType) } if c.TimeoutMs <= 0 { diff --git a/modules/adcontextprotocol/tmp/config_test.go b/modules/adcontextprotocol/tmp/config_test.go index 24c43fb6888..ca00e6e7c6f 100644 --- a/modules/adcontextprotocol/tmp/config_test.go +++ b/modules/adcontextprotocol/tmp/config_test.go @@ -34,7 +34,9 @@ func validConfig(t *testing.T) Config { PrivateKeyPEM: genTestKey(t), }, PropertyRegistry: PropertyRegistryConfig{ - Endpoint: "https://agenticadvertising.org/api/properties/resolve", + Endpoint: "https://agenticadvertising.org/api/registry/resolve", + Mode: "resolve", + AuthBearer: "test-bearer", }, Providers: []ProviderConfig{ { @@ -65,6 +67,59 @@ func TestValidated_Defaults(t *testing.T) { } } +func TestValidated_PropertyRegistryDefaults(t *testing.T) { + cfg := validConfig(t) + cfg.PropertyRegistry.Endpoint = "" + cfg.PropertyRegistry.Mode = "" + cfg.PropertyRegistry.ProvenanceType = "" + if _, err := cfg.validated(); err != nil { + t.Fatalf("expected valid config, got %v", err) + } + if cfg.PropertyRegistry.Endpoint != "https://agenticadvertising.org/api/registry/resolve" { + t.Errorf("endpoint default = %q", cfg.PropertyRegistry.Endpoint) + } + if cfg.PropertyRegistry.Mode != "resolve" { + t.Errorf("mode default = %q, want resolve", cfg.PropertyRegistry.Mode) + } + if cfg.PropertyRegistry.ProvenanceType != "member_assertion" { + t.Errorf("provenance_type default = %q, want member_assertion", cfg.PropertyRegistry.ProvenanceType) + } +} + +func TestValidated_PropertyRegistryRejectsBadMode(t *testing.T) { + cfg := validConfig(t) + cfg.PropertyRegistry.Mode = "contribute" + if _, err := cfg.validated(); err == nil { + t.Fatal("expected error on invalid mode") + } +} + +func TestValidated_PropertyRegistryRejectsBadProvenance(t *testing.T) { + cfg := validConfig(t) + cfg.PropertyRegistry.ProvenanceType = "crawl" // reserved for server-side pipelines + if _, err := cfg.validated(); err == nil { + t.Fatal("expected error on reserved provenance type") + } +} + +func TestValidated_PropertyRegistryResolveRequiresBearer(t *testing.T) { + cfg := validConfig(t) + cfg.PropertyRegistry.Mode = "resolve" + cfg.PropertyRegistry.AuthBearer = "" + if _, err := cfg.validated(); err == nil { + t.Fatal("expected error when mode=resolve without bearer") + } +} + +func TestValidated_PropertyRegistryLookupAllowsMissingBearer(t *testing.T) { + cfg := validConfig(t) + cfg.PropertyRegistry.Mode = "lookup" + cfg.PropertyRegistry.AuthBearer = "" + if _, err := cfg.validated(); err != nil { + t.Fatalf("expected mode=lookup without bearer to be valid; got %v", err) + } +} + func TestValidated_ProviderNeedsAtLeastOneURL(t *testing.T) { cfg := validConfig(t) cfg.Providers[0].IdentityURL = "" diff --git a/modules/adcontextprotocol/tmp/hooks_test.go b/modules/adcontextprotocol/tmp/hooks_test.go index 3e74da68a8f..a463283eca6 100644 --- a/modules/adcontextprotocol/tmp/hooks_test.go +++ b/modules/adcontextprotocol/tmp/hooks_test.go @@ -28,14 +28,8 @@ import ( func newHooksFixtureModule(t *testing.T) (*Module, func()) { t.Helper() registry := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _ = json.NewEncoder(w).Encode(map[string]any{ - "property": map[string]any{ - "property_rid": "01916f3a-1234-7000-8000-000000000001", - "property_id": "fixture", - "property_type": "website", - "domain": r.URL.Query().Get("domain"), - }, - }) + domain := decodeResolveRequest(t, r) + _ = json.NewEncoder(w).Encode(resolvedShape("01916f3a-1234-7000-8000-000000000001", "website", domain)) })) provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { @@ -62,7 +56,7 @@ func newHooksFixtureModule(t *testing.T) (*Module, func()) { KeyID: "kid-1", PrivateKeyPEM: genTestKey(t), }, - PropertyRegistry: PropertyRegistryConfig{Endpoint: registry.URL}, + PropertyRegistry: PropertyRegistryConfig{Endpoint: registry.URL, Mode: "lookup"}, Providers: []ProviderConfig{{ Name: "prov", IdentityURL: provider.URL + "/identity", diff --git a/modules/adcontextprotocol/tmp/property_registry.go b/modules/adcontextprotocol/tmp/property_registry.go index 7e408a2206c..c03747e2c08 100644 --- a/modules/adcontextprotocol/tmp/property_registry.go +++ b/modules/adcontextprotocol/tmp/property_registry.go @@ -1,13 +1,13 @@ package tmp import ( + "bytes" "container/list" "context" "errors" "fmt" "io" "net/http" - "net/url" "strings" "sync" "time" @@ -16,8 +16,11 @@ import ( "github.com/prebid/prebid-server/v4/util/jsonutil" ) -// PropertyRecord is the subset of a registry property record the module needs. -// The registry may return more fields — we ignore what we don't use. +// PropertyRecord is the subset of a catalog resolve result the module +// keeps. The catalog returns more (identifiers, classification, source, +// status); we care about property_rid for the TMP wire and optionally +// property_type so context_match_request carries it when the catalog +// knows it. type PropertyRecord struct { PropertyRID string `json:"property_rid"` PropertyID string `json:"property_id"` @@ -25,13 +28,40 @@ type PropertyRecord struct { Domain string `json:"domain"` } -// registryResponse mirrors the resolve endpoint's JSON envelope. The spec at -// agenticadvertising.org returns either a single property or a "not found" -// signal — modeled here so callers can distinguish "no such domain" from an -// upstream error. -type registryResponse struct { - Property *PropertyRecord `json:"property"` - Found *bool `json:"found,omitempty"` +// registryResolveRequest is the POST body for /api/registry/resolve. +// Matches adcp catalog-openapi.ts ResolveRequestSchema. +type registryResolveRequest struct { + Identifiers []registryIdentifier `json:"identifiers"` + Provenance registryProvenance `json:"provenance"` + Mode string `json:"mode"` +} + +type registryIdentifier struct { + Type string `json:"type"` + Value string `json:"value"` +} + +type registryProvenance struct { + Type string `json:"type"` + Context string `json:"context,omitempty"` +} + +// registryResolveResponse mirrors adcp ResolveResponseSchema. Only the +// resolved[] entries are load-bearing here — the summary and timestamp +// are ignored. +type registryResolveResponse struct { + Resolved []registryResolvedEntry `json:"resolved"` +} + +// registryResolvedEntry mirrors ResolvedEntrySchema. property_rid is +// nullable — the catalog returns null for excluded (ad_infra / +// publisher_mask) identifiers and for unresolved lookups. +type registryResolvedEntry struct { + Identifier registryIdentifier `json:"identifier"` + PropertyRID *string `json:"property_rid"` + Classification string `json:"classification"` + Status string `json:"status"` + Source *string `json:"source"` } // propertyResolver resolves site.domain / app.bundle → PropertyRecord with an @@ -112,22 +142,28 @@ func (p *propertyResolver) Resolve(ctx context.Context, domain string) (*Propert return rec, true, nil } -func (p *propertyResolver) fetch(ctx context.Context, domain string) (*PropertyRecord, error) { - q := url.Values{} - q.Set("domain", domain) - fullURL := p.cfg.Endpoint - if strings.Contains(fullURL, "?") { - fullURL += "&" + q.Encode() - } else { - fullURL += "?" + q.Encode() +func (p *propertyResolver) fetch(ctx context.Context, key string) (*PropertyRecord, error) { + identType := identifierTypeFor(key) + body := registryResolveRequest{ + Identifiers: []registryIdentifier{{Type: identType, Value: key}}, + Provenance: registryProvenance{ + Type: p.cfg.ProvenanceType, + Context: p.cfg.ProvenanceContext, + }, + Mode: p.cfg.Mode, + } + raw, err := jsonutil.Marshal(body) + if err != nil { + return nil, fmt.Errorf("registry marshal: %w", err) } - req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.cfg.Endpoint, bytes.NewReader(raw)) if err != nil { return nil, err } if p.cfg.AuthBearer != "" { req.Header.Set("Authorization", "Bearer "+p.cfg.AuthBearer) } + req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") resp, err := p.http.Do(req) @@ -135,39 +171,36 @@ func (p *propertyResolver) fetch(ctx context.Context, domain string) (*PropertyR return nil, err } defer func() { - // Drain and close so keep-alive can reuse the connection. _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<16)) _ = resp.Body.Close() }() switch resp.StatusCode { case http.StatusOK: - // 64 KiB is generous for a single property record. - raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<16)) + // 64 KiB is generous for a single-identifier resolve reply. + respBody, err := io.ReadAll(io.LimitReader(resp.Body, 1<<16)) if err != nil { return nil, fmt.Errorf("registry read: %w", err) } - // Registry implementations vary. Try the wrapped - // {"property": {...}} envelope first; if that yields no - // property_rid, try decoding the payload as a bare - // PropertyRecord — some deployments (including - // agenticadvertising.org's /api/properties/resolve) return the - // record directly, not nested. - var body registryResponse - if err := jsonutil.Unmarshal(raw, &body); err != nil { + var decoded registryResolveResponse + if err := jsonutil.Unmarshal(respBody, &decoded); err != nil { return nil, fmt.Errorf("registry decode: %w", err) } - if body.Property != nil && body.Property.PropertyRID != "" { - return body.Property, nil - } - if body.Found != nil && !*body.Found { + if len(decoded.Resolved) == 0 { return nil, nil } - var bare PropertyRecord - if err := jsonutil.Unmarshal(raw, &bare); err == nil && bare.PropertyRID != "" { - return &bare, nil + entry := decoded.Resolved[0] + if entry.PropertyRID == nil || *entry.PropertyRID == "" { + // null property_rid = excluded (ad_infra/publisher_mask) or + // unresolved-in-lookup. Both cache as negative — the module + // short-circuits without calling agents. + return nil, nil } - return nil, nil + return &PropertyRecord{ + PropertyRID: *entry.PropertyRID, + PropertyType: propertyTypeFor(entry.Classification), + Domain: key, + }, nil case http.StatusNotFound: return nil, nil case http.StatusUnauthorized, http.StatusForbidden: @@ -177,6 +210,62 @@ func (p *propertyResolver) fetch(ctx context.Context, domain string) (*PropertyR } } +// identifierTypeFor picks the adcp CatalogIdentifier.type for a lookup +// key. Bare app-bundle identifiers (`com.example.app`) and reverse-DNS +// forms use `bundle`; everything else — including plain domains and +// domains that happen to contain dots — is `domain`. The heuristic is +// conservative: if the key contains an ASCII letter-only leading +// segment ending in a dot before another letter-only segment ("com.", +// "io.", "app."), treat it as a bundle; otherwise domain. False +// positives here fall through to the negative cache, not to a wrong +// property record. +func identifierTypeFor(key string) string { + if isLikelyBundleID(key) { + return "bundle" + } + return "domain" +} + +func isLikelyBundleID(s string) bool { + // Bundle IDs are reverse-DNS with a top-level TLD-shaped prefix + // (`com`, `io`, `org`, `net`, `app`, etc.) followed by a dot. + // Domains use the TLD at the *end*. A single-segment string can't + // be a bundle. Anything with a slash or colon is neither — those + // are stripped earlier by isValidDomainOrBundle but be defensive. + if strings.ContainsAny(s, "/:") { + return false + } + firstDot := strings.IndexByte(s, '.') + if firstDot <= 0 || firstDot == len(s)-1 { + return false + } + prefix := s[:firstDot] + switch prefix { + case "com", "io", "org", "net", "app", "co", "dev": + return true + } + return false +} + +// propertyTypeFor maps the catalog's `classification` string to the +// tmproto property_type enum used on the TMP wire. `property` is the +// generic bucket the catalog returns for anything that isn't a specific +// media type; leave PropertyType empty in that case so the derived +// PropertyType heuristic in the OpenRTB adapter fills it in. +func propertyTypeFor(classification string) tmproto.PropertyType { + switch classification { + case "website": + return "website" + case "mobile_app": + return "mobile_app" + case "ctv": + return "ctv" + case "dooh": + return "dooh" + } + return "" +} + func (p *propertyResolver) cacheGet(key string) (*PropertyRecord, bool, bool) { p.mu.Lock() defer p.mu.Unlock() diff --git a/modules/adcontextprotocol/tmp/property_registry_test.go b/modules/adcontextprotocol/tmp/property_registry_test.go index 9484b681b21..9ff77417db6 100644 --- a/modules/adcontextprotocol/tmp/property_registry_test.go +++ b/modules/adcontextprotocol/tmp/property_registry_test.go @@ -4,31 +4,82 @@ import ( "context" "encoding/json" "fmt" + "io" "net/http" "net/http/httptest" "sync/atomic" "testing" ) +// resolvedShape emits an adcp ResolveResponse envelope. Prod uses this +// exact shape (see adcp catalog-openapi.ts / server registry-api.ts). +func resolvedShape(propertyRID, classification, domain string) map[string]any { + return map[string]any{ + "resolved": []map[string]any{{ + "identifier": map[string]any{"type": "domain", "value": domain}, + "property_rid": propertyRID, + "classification": classification, + "status": "existing", + "source": "authoritative", + }}, + "summary": map[string]any{"total": 1, "resolved": 1}, + "server_timestamp": "2026-01-01T00:00:00Z", + } +} + +// unresolvedShape emits a null-property_rid entry — the shape the +// registry uses for excluded (ad_infra / publisher_mask) identifiers +// and for lookups that hit nothing. Both cache as negative in the +// module. +func unresolvedShape(domain string) map[string]any { + return map[string]any{ + "resolved": []map[string]any{{ + "identifier": map[string]any{"type": "domain", "value": domain}, + "property_rid": nil, + "classification": "unknown", + "status": "existing", + "source": nil, + }}, + "summary": map[string]any{"total": 1, "resolved": 0, "not_found": 1}, + "server_timestamp": "2026-01-01T00:00:00Z", + } +} + +// decodeResolveRequest returns the domain value the caller sent, so +// tests can echo it back in the response envelope. +func decodeResolveRequest(t *testing.T, r *http.Request) string { + t.Helper() + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + var body registryResolveRequest + raw, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + if err := json.Unmarshal(raw, &body); err != nil { + t.Fatalf("decode body: %v", err) + } + if len(body.Identifiers) != 1 { + t.Fatalf("expected 1 identifier, got %d", len(body.Identifiers)) + } + return body.Identifiers[0].Value +} + func TestPropertyResolver_Cache(t *testing.T) { var calls int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { atomic.AddInt32(&calls, 1) - domain := r.URL.Query().Get("domain") + domain := decodeResolveRequest(t, r) w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{ - "property": map[string]any{ - "property_rid": "01916f3a-1234-7000-8000-000000000001", - "property_id": "example", - "property_type": "website", - "domain": domain, - }, - }) + _ = json.NewEncoder(w).Encode(resolvedShape("01916f3a-1234-7000-8000-000000000001", "website", domain)) })) defer srv.Close() r := newPropertyResolver(PropertyRegistryConfig{ Endpoint: srv.URL, + Mode: "lookup", + ProvenanceType: "member_assertion", CacheTTLSeconds: 60, CacheSize: 16, TimeoutMs: 500, @@ -48,9 +99,43 @@ func TestPropertyResolver_Cache(t *testing.T) { } } -func TestPropertyResolver_NotFound_NegativelyCached(t *testing.T) { +func TestPropertyResolver_NullRID_NegativelyCached(t *testing.T) { var calls int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&calls, 1) + domain := decodeResolveRequest(t, r) + _ = json.NewEncoder(w).Encode(unresolvedShape(domain)) + })) + defer srv.Close() + + r := newPropertyResolver(PropertyRegistryConfig{ + Endpoint: srv.URL, + Mode: "lookup", + ProvenanceType: "member_assertion", + CacheTTLSeconds: 60, + NegativeCacheTTLSeconds: 60, + CacheSize: 16, + TimeoutMs: 500, + }, nil) + + ctx := context.Background() + for i := range 3 { + rec, ok, err := r.Resolve(ctx, "nowhere.example") + if err != nil { + t.Fatalf("resolve[%d]: %v", i, err) + } + if ok || rec != nil { + t.Fatalf("resolve[%d]: expected not-found, got rec=%+v ok=%v", i, rec, ok) + } + } + if got := atomic.LoadInt32(&calls); got != 1 { + t.Errorf("expected 1 upstream call (rest served from negative cache), got %d", got) + } +} + +func TestPropertyResolver_NotFound_NegativelyCached(t *testing.T) { + var calls int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { atomic.AddInt32(&calls, 1) w.WriteHeader(http.StatusNotFound) })) @@ -58,6 +143,8 @@ func TestPropertyResolver_NotFound_NegativelyCached(t *testing.T) { r := newPropertyResolver(PropertyRegistryConfig{ Endpoint: srv.URL, + Mode: "lookup", + ProvenanceType: "member_assertion", CacheTTLSeconds: 60, NegativeCacheTTLSeconds: 60, CacheSize: 16, @@ -80,15 +167,17 @@ func TestPropertyResolver_NotFound_NegativelyCached(t *testing.T) { } func TestPropertyResolver_UpstreamError(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusInternalServerError) })) defer srv.Close() r := newPropertyResolver(PropertyRegistryConfig{ - Endpoint: srv.URL, - CacheSize: 4, - TimeoutMs: 500, + Endpoint: srv.URL, + Mode: "lookup", + ProvenanceType: "member_assertion", + CacheSize: 4, + TimeoutMs: 500, }, nil) _, _, err := r.Resolve(context.Background(), "x.example") if err == nil { @@ -97,15 +186,18 @@ func TestPropertyResolver_UpstreamError(t *testing.T) { } func TestPropertyResolver_BearerAuth(t *testing.T) { - var sawAuth string + var sawAuth, sawContentType string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { sawAuth = r.Header.Get("Authorization") + sawContentType = r.Header.Get("Content-Type") w.WriteHeader(http.StatusNotFound) })) defer srv.Close() r := newPropertyResolver(PropertyRegistryConfig{ Endpoint: srv.URL, + Mode: "resolve", + ProvenanceType: "member_assertion", AuthBearer: "secret-token", NegativeCacheTTLSeconds: 60, CacheSize: 4, @@ -115,25 +207,60 @@ func TestPropertyResolver_BearerAuth(t *testing.T) { if sawAuth != "Bearer secret-token" { t.Errorf("Authorization header = %q, want %q", sawAuth, "Bearer secret-token") } + if sawContentType != "application/json" { + t.Errorf("Content-Type header = %q, want application/json", sawContentType) + } +} + +// TestPropertyResolver_ProvenanceForwarded verifies the caller-configured +// provenance envelope reaches the upstream unchanged. The registry keys +// trust decisions off provenance.type, so a bug here would silently +// misattribute every resolve. +func TestPropertyResolver_ProvenanceForwarded(t *testing.T) { + var seen registryProvenance + var seenMode string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body registryResolveRequest + raw, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(raw, &body) + seen = body.Provenance + seenMode = body.Mode + _ = json.NewEncoder(w).Encode(resolvedShape("01916f3a-1234-7000-8000-000000000002", "website", "x.example")) + })) + defer srv.Close() + + r := newPropertyResolver(PropertyRegistryConfig{ + Endpoint: srv.URL, + Mode: "lookup", + ProvenanceType: "publisher_declaration", + ProvenanceContext: "prebid-integration", + CacheSize: 4, + TimeoutMs: 500, + }, nil) + _, _, err := r.Resolve(context.Background(), "x.example") + if err != nil { + t.Fatalf("resolve: %v", err) + } + if seen.Type != "publisher_declaration" || seen.Context != "prebid-integration" { + t.Errorf("provenance forwarded incorrectly: got %+v", seen) + } + if seenMode != "lookup" { + t.Errorf("mode forwarded incorrectly: got %q", seenMode) + } } // Trigger LRU eviction to make sure the cache does not grow unbounded. func TestPropertyResolver_LRUEviction(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - domain := r.URL.Query().Get("domain") - _ = json.NewEncoder(w).Encode(map[string]any{ - "property": map[string]any{ - "property_rid": "rid-" + domain, - "property_id": domain, - "property_type": "website", - "domain": domain, - }, - }) + domain := decodeResolveRequest(t, r) + _ = json.NewEncoder(w).Encode(resolvedShape("rid-"+domain, "website", domain)) })) defer srv.Close() r := newPropertyResolver(PropertyRegistryConfig{ Endpoint: srv.URL, + Mode: "lookup", + ProvenanceType: "member_assertion", CacheTTLSeconds: 60, CacheSize: 2, TimeoutMs: 500, diff --git a/modules/adcontextprotocol/tmp/router_test.go b/modules/adcontextprotocol/tmp/router_test.go index 7c7c5dbe52c..f334561abdb 100644 --- a/modules/adcontextprotocol/tmp/router_test.go +++ b/modules/adcontextprotocol/tmp/router_test.go @@ -29,15 +29,8 @@ func newFixture(t *testing.T) *tmpFixture { t.Helper() f := &tmpFixture{} f.Registry = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - domain := r.URL.Query().Get("domain") - _ = json.NewEncoder(w).Encode(map[string]any{ - "property": map[string]any{ - "property_rid": "01916f3a-1234-7000-8000-000000000001", - "property_id": "fixture", - "property_type": "website", - "domain": domain, - }, - }) + domain := decodeResolveRequest(t, r) + _ = json.NewEncoder(w).Encode(resolvedShape("01916f3a-1234-7000-8000-000000000001", "website", domain)) })) f.Provider = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { @@ -74,7 +67,7 @@ func newFixture(t *testing.T) *tmpFixture { KeyID: "kid-1", PrivateKeyPEM: genTestKey(t), }, - PropertyRegistry: PropertyRegistryConfig{Endpoint: f.Registry.URL}, + PropertyRegistry: PropertyRegistryConfig{Endpoint: f.Registry.URL, Mode: "lookup"}, Providers: []ProviderConfig{{ Name: "prov", IdentityURL: f.Provider.URL + "/identity", @@ -181,6 +174,8 @@ func TestFanOut_UnknownDomainReturnsEmpty(t *testing.T) { })) f.Module.registry = newPropertyResolver(PropertyRegistryConfig{ Endpoint: f.Registry.URL, + Mode: "lookup", + ProvenanceType: "member_assertion", NegativeCacheTTLSeconds: 60, CacheSize: 4, TimeoutMs: 500, From 83ed4d3d88f995504ddcca3b50c6775f648315ce Mon Sep 17 00:00:00 2001 From: Oleksandr Halushchak Date: Thu, 6 Aug 2026 14:34:40 +0200 Subject: [PATCH 09/11] Provider URL docs: spell out the at-least-one invariant Move the "at least one of IdentityURL or ContextURL is required" rule onto the struct doc and expand each field's comment to describe what happens when it is empty. The rule was already enforced in validated(), but the shape of the type didn't make it obvious a reader had to jump there to know it. --- modules/adcontextprotocol/tmp/config.go | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/modules/adcontextprotocol/tmp/config.go b/modules/adcontextprotocol/tmp/config.go index 7ef0b8c5f01..502510fe4ee 100644 --- a/modules/adcontextprotocol/tmp/config.go +++ b/modules/adcontextprotocol/tmp/config.go @@ -169,12 +169,19 @@ type PropertyRegistryConfig struct { } // ProviderConfig describes a single downstream TMP provider (identity agent, -// context agent, or both). +// context agent, or both). At least one of IdentityURL or ContextURL MUST +// be set — the two fields are independently optional but not both empty; +// validated() rejects an all-empty pair. type ProviderConfig struct { Name string `json:"name"` - // IdentityURL, if set, receives IdentityMatch requests. + // IdentityURL is the provider's /identity endpoint. Empty means this + // provider does not serve identity — the router skips its identity + // call and any offers pass through the eligibility gate unfiltered. IdentityURL string `json:"identity_url"` - // ContextURL, if set, receives ContextMatch requests. + // ContextURL is the provider's /context endpoint. Empty means this + // provider does not serve context — the router does not fetch offers + // from it, and its identity eligibility (if configured) contributes + // nothing on its own. ContextURL string `json:"context_url"` // TimeoutMs overrides the module-level timeout for this provider. Optional. TimeoutMs int `json:"timeout_ms"` From 723c921435b797a83464c9865c965b843eff8f1d Mon Sep 17 00:00:00 2001 From: Oleksandr Halushchak Date: Thu, 6 Aug 2026 14:59:22 +0200 Subject: [PATCH 10/11] SellerAgentURL docs: spell out the three-surface canonical-URL invariant The URL is folded into the Ed25519 signing preimage, so a mismatch between the value here, the publisher's adagents.json authorized_agents[].url, and the AAO /api/registry/authorizations row that carries our signing_keys[] is a hard 401 at every receiving agent. Document that plus AdCP URL canonicalization (case/port/ trailing-slash normalized, path significant) so operators see the contract at the point of definition rather than reverse-engineering it from the receiver's error messages. --- modules/adcontextprotocol/tmp/config.go | 35 ++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/modules/adcontextprotocol/tmp/config.go b/modules/adcontextprotocol/tmp/config.go index 502510fe4ee..67d990a135f 100644 --- a/modules/adcontextprotocol/tmp/config.go +++ b/modules/adcontextprotocol/tmp/config.go @@ -12,10 +12,37 @@ import ( // Config is the JSON configuration for the module. See README.md. type Config struct { - // SellerAgentURL identifies this Prebid Server deployment as a seller agent. - // MUST match one of the property's adagents.json authorized_agents[].url - // entries (compared with AdCP URL canonicalization). Same value for every - // user on a given placement — carries no user identity. + // SellerAgentURL identifies this Prebid Server deployment as a seller + // agent. The value is sent on every outbound context / identity + // request AND is folded into the Ed25519 signing preimage — so it is + // the receiving agent's handle on this deployment for both package + // evaluation and signature verification. Same value for every user + // on a given placement; carries no user identity. + // + // Operational contract — the same canonical URL MUST resolve in all + // three of these places, or the receiving agent has no way to + // verify us: + // + // 1. The publisher's adagents.json `authorized_agents[].url` for + // every property served by this deployment (spec `agent_url` + // per adcp docs/reference/url-canonicalization). + // 2. A row in the AdCP registry's `/api/registry/authorizations` + // endpoint whose `agent_url` matches this value and whose + // `signing_keys[]` carries the Ed25519 pubkey paired with + // Signing.KeyID below (adcp-go tmproto.LazyAuthorizationKeyStore + // queries `?agent_url=` to fetch it). + // 3. This config field. + // + // All comparisons are AdCP URL canonicalization, not byte-equality + // (see adcp docs/reference/url-canonicalization and + // tmproto.NormalizeProviderEndpointURL) — case, default ports, and + // trailing-slash normalization are transparent, but path is + // significant, so e.g. `https://seller.example.com/` and + // `https://seller.example.com/agent` are different agents. A + // mismatch in any of the three surfaces yields a hard 401 at the + // receiving agent (ErrSignatureKeyUnknown), because seller_agent_url + // is bound into the signed input — a request that reports one URL + // but is signed under another cannot be verified. SellerAgentURL string `json:"seller_agent_url"` // PropertyType default when the registry does not return one. Optional. From 45bb94d7af2babd225e8c6abaf34c054c18fde2c Mon Sep 17 00:00:00 2001 From: Oleksandr Halushchak Date: Tue, 11 Aug 2026 12:32:25 +0200 Subject: [PATCH 11/11] Address integrator feedback: docs, country normalization, signing.disabled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes surfaced by a hosted PBS-Go deployment that hit silent-failure traps between the module and a live TMP verifier. Docs (README): - Correct the stage identifier: auction_processed → processed_auction_request. Prebid silently ignores unknown stage names, so the wrong one produces a green trace and no fan-out. - Note that property_registry.mode=lookup still requires auth_bearer against hosted registries — the AdCP registry rejects unauthenticated POSTs with 403 CSRF regardless of mode. - Call out that provider identity_url/context_url paths are signed; bare origins 404. - Spell out the providers[].name charset — underscores only, no hyphens or dots (matches the adcp provider_id regex). - New "Deployment" section explaining that Prebid's Viper AutomaticEnv does not overlay hooks.modules.*, so ${ENV_VAR} substitution in module config is illustrative only. Recommend template rendering at pod startup for secrets. Country normalization: - OpenRTB device.geo.country is ISO 3166-1 alpha-3 ("USA"); the TMP wire is alpha-2 ("US") — adcp-go's ValidateIdentityRequest hard-400s anything else. Add iso3166.go with the full ISO 3166-1 alpha-3 → alpha-2 table plus normalizeCountryToAlpha2; wire it into both IdentityMatchRequest.Country and the context request's geo.country map so spec-compliant OpenRTB traffic reaches the verifier cleanly. Refuses the "first two characters" heuristic (would misclassify AUT→AU, IRL→IR, PRT→PR, and other silent traps). Signing escape hatch: - Add signing.disabled to SigningConfig. When true, outbound requests ship without X-AdCP-Signature / X-AdCP-Key-Id, and key_id / private_key_pem become optional. Builder logs a loud WARN at every startup so the flag is visible in ops logs; validated() rejects the combination of disabled=true and stale key material so a later flip back to enabled has to re-supply keys deliberately. - Intended for pre-production rollout against verifiers running TMP_ALLOW_UNSIGNED=true. README and doc-comment explicitly gate to non-production use. --- modules/adcontextprotocol/tmp/README.md | 43 ++++++- modules/adcontextprotocol/tmp/adapter.go | 8 +- modules/adcontextprotocol/tmp/config.go | 47 ++++++-- modules/adcontextprotocol/tmp/config_test.go | 34 ++++++ modules/adcontextprotocol/tmp/iso3166.go | 106 ++++++++++++++++++ modules/adcontextprotocol/tmp/iso3166_test.go | 51 +++++++++ modules/adcontextprotocol/tmp/module.go | 12 +- .../adcontextprotocol/tmp/provider_client.go | 35 ++++-- 8 files changed, 304 insertions(+), 32 deletions(-) create mode 100644 modules/adcontextprotocol/tmp/iso3166.go create mode 100644 modules/adcontextprotocol/tmp/iso3166_test.go diff --git a/modules/adcontextprotocol/tmp/README.md b/modules/adcontextprotocol/tmp/README.md index 6226f7fb04c..0f808fb1e90 100644 --- a/modules/adcontextprotocol/tmp/README.md +++ b/modules/adcontextprotocol/tmp/README.md @@ -38,9 +38,15 @@ hooks: # resolved[0].property_rid. endpoint: https://agenticadvertising.org/api/registry/resolve # "resolve" (default) contributes the identifier to the catalog - # and requires auth_bearer. "lookup" is a pure read with no auth - # and returns null property_rid for unknown identifiers. + # and requires auth_bearer. "lookup" is a read-only variant that + # skips catalog contribution — but hosted registries typically + # reject unauthenticated POSTs with 403 CSRF, so a bearer is + # required in practice for both modes. mode: resolve + # Bearer for the property catalog. Required for `mode: resolve`; + # for `mode: lookup` supply one unless you know the target + # registry accepts unauthenticated traffic (agenticadvertising.org + # does not). auth_bearer: ${ADCP_REGISTRY_TOKEN} # FactProvenance.type — how the catalog attributes this request. # See adcp catalog-openapi.ts FactProvenance for the enum. @@ -121,7 +127,7 @@ hooks: endpoints: /openrtb2/auction: stages: - auction_processed: + processed_auction_request: groups: - timeout: 500 hook_sequence: @@ -142,11 +148,12 @@ hooks: | `seller_agent_url` | Publicly reachable URL identifying this Prebid Server deployment as a seller agent. Must appear as one of `authorized_agents[].url` in the publisher's `adagents.json` (compared under AdCP URL canonicalization). | | `signing.key_id` | Sent in `X-AdCP-Key-Id`. Verifiers use it to look up the matching Ed25519 public key. | | `signing.private_key_pem` | PEM-encoded PKCS#8 Ed25519 private key. | +| `signing.disabled` | Optional. When `true`, outbound requests are sent WITHOUT `X-AdCP-Signature` / `X-AdCP-Key-Id` headers, and `key_id` / `private_key_pem` become optional. **Non-production only** — for pre-production rollout against verifiers running `TMP_ALLOW_UNSIGNED=true`. Builder logs a loud WARN at every startup so the flag is visible in ops logs. | | `property_registry.endpoint` | Resolves `site.domain` / `app.bundle` → `property_rid` via `POST /api/registry/resolve` (adcp `ResolveRequest`/`ResolveResponse`). Defaults to `https://agenticadvertising.org/api/registry/resolve` when omitted. | -| `property_registry.mode` | `resolve` (default) contributes to the catalog and requires `auth_bearer`; `lookup` is an unauthenticated pure read. | +| `property_registry.mode` | `resolve` (default) contributes to the catalog and requires `auth_bearer`; `lookup` is a read-only variant that skips catalog contribution. Both modes typically require `auth_bearer` in practice — hosted registries (including `agenticadvertising.org`) reject unauthenticated POSTs with `403 CSRF` regardless of mode. Only omit the bearer when pointing at a registry you know accepts unauthenticated traffic. | | `property_registry.provenance_type` | Enum from adcp `FactProvenance.type`. Default `member_assertion`. `crawl` is reserved for server-side pipelines and rejected. | -| `providers[].name` | Stable provider identifier (adcp `provider_id`). Appears verbatim in logs, metrics, and as the outer key of `tmpx_macro_mapping`. Charset matches the adcp spec: `^[A-Za-z0-9_]{1,64}$`. | -| `providers[].identity_url` or `providers[].context_url` | At least one is required per provider. | +| `providers[].name` | Stable provider identifier (adcp `provider_id`). Appears verbatim in logs, metrics, and as the outer key of `tmpx_macro_mapping`. Charset is the adcp spec `^[A-Za-z0-9_]{1,64}$` — **underscores only, no hyphens or dots**. A name like `some-provider` is rejected at Builder startup. | +| `providers[].identity_url` or `providers[].context_url` | At least one is required per provider. Full path is significant — the module signs the canonicalized endpoint (including path) via `tmproto.NormalizeProviderEndpointURL`, and the verifier signs the same, so a bare-origin `https://tmp.example.com` will 404. Always include `/identity` and `/context` explicitly. | | `providers[].tmpx_slots` | Optional. Ordered list of `slot_id`s the provider registered in adcp `provider-registration.json`. Required when the provider emits TMPX. The module drops any provider response whose emitted slot sequence is not a non-empty ordered prefix of this list. | | `tmpx_macro_mapping` | Optional. Publisher-owned map of `provider_id → slot_id → ad-server macro name` used to route each provider's TMPX chunks. Omit to disable TMPX targeting. Missing entries for a provider's registered slots produce a startup warning; unmapped slots seen at serve time fail closed. | @@ -210,6 +217,30 @@ When `add_to_targeting: true`, each `key=value` pair is also mirrored into `ext.prebid.targeting` so downstream ad servers (e.g. Google Ad Manager) can consume them without a custom bridge. +## Deployment + +### Secrets and env-var substitution + +The `${VAR}` shell-style expansions above (e.g. `${ADCP_TMP_SIGNING_KEY_PEM}`, +`${ADCP_REGISTRY_TOKEN}`) are illustrative only — Prebid Server does not +resolve env vars inside `hooks.modules.*`. Viper's `AutomaticEnv` binds +top-level config keys but never traverses this subtree, so a literal +`${ADCP_TMP_SIGNING_KEY_PEM}` will reach Builder unchanged and be rejected +as an unparseable PEM. + +The supported pattern is to render `pbs.yaml` from a template at pod +startup (init container, entrypoint script, or config-mount rewrite), +with real values substituted before Prebid reads the file. Do not rely +on Viper env-var overlay for module secrets. + +### OpenRTB `device.geo.country` + +OpenRTB defines `device.geo.country` as ISO 3166-1 alpha-3 (`"USA"`), +while the TMP wire requires alpha-2 (`"US"`). The module converts alpha-3 +→ alpha-2 before signing via a bundled ISO 3166-1 lookup table; values +that are already alpha-2 pass through, and unrecognized shapes are dropped +so the receiver's validator sees a clean field. + ## Privacy - The TMP wire is decorrelated by design: context requests carry no identity diff --git a/modules/adcontextprotocol/tmp/adapter.go b/modules/adcontextprotocol/tmp/adapter.go index 7606cbfef92..bd4e2dc6eac 100644 --- a/modules/adcontextprotocol/tmp/adapter.go +++ b/modules/adcontextprotocol/tmp/adapter.go @@ -77,10 +77,10 @@ func deriveInputs(cfg *Config, req *openrtb2.BidRequest) tmpInputs { if req.Device != nil && req.Device.Geo != nil { out.Geo = coarseGeo(cfg, req.Device.Geo) - out.Country = req.Device.Geo.Country + out.Country = normalizeCountryToAlpha2(req.Device.Geo.Country) } else if req.User != nil && req.User.Geo != nil { out.Geo = coarseGeo(cfg, req.User.Geo) - out.Country = req.User.Geo.Country + out.Country = normalizeCountryToAlpha2(req.User.Geo.Country) } if req.User != nil { @@ -129,8 +129,8 @@ func coarseGeo(cfg *Config, geo *openrtb2.Geo) map[string]any { } out := map[string]any{} - if geo.Country != "" { - out["country"] = geo.Country + if a2 := normalizeCountryToAlpha2(geo.Country); a2 != "" { + out["country"] = a2 } if geo.Region != "" { out["region"] = geo.Region diff --git a/modules/adcontextprotocol/tmp/config.go b/modules/adcontextprotocol/tmp/config.go index 67d990a135f..874c8c4fe59 100644 --- a/modules/adcontextprotocol/tmp/config.go +++ b/modules/adcontextprotocol/tmp/config.go @@ -152,6 +152,22 @@ type SigningConfig struct { // substitute this from the environment via yaml env expansion (e.g. // ${ADCP_TMP_SIGNING_KEY_PEM}) — the module itself receives it as a string. PrivateKeyPEM string `json:"private_key_pem"` + + // Disabled, when true, skips signing every outbound context / identity + // request — the module omits both the X-AdCP-Signature and + // X-AdCP-Key-Id headers. Intended for pre-production and rollout + // scenarios where the verifier runs with TMP_ALLOW_UNSIGNED=true and + // the seller's adagents.json / registry authorization is still + // propagating. When Disabled=true, key_id and private_key_pem are + // optional; otherwise both are required. + // + // Setting this in production leaks the seller identity's signing + // guarantee — the verifier can no longer prove the request came from + // this deployment, so relay attackers and misconfigured peers become + // indistinguishable from legitimate traffic. Builder logs a loud WARN + // at every startup so an unintended flag is visible in ops logs. + // DO NOT use in production. + Disabled bool `json:"disabled"` } // PropertyRegistryConfig configures the domain → property_rid resolver. @@ -283,15 +299,28 @@ func (c *Config) validated() (ed25519.PrivateKey, error) { if c.SellerAgentURL == "" { return nil, errors.New("seller_agent_url is required") } - if c.Signing.KeyID == "" { - return nil, errors.New("signing.key_id is required") - } - if c.Signing.PrivateKeyPEM == "" { - return nil, errors.New("signing.private_key_pem is required") - } - priv, err := tmproto.LoadEd25519PrivateKeyPEM([]byte(c.Signing.PrivateKeyPEM)) - if err != nil { - return nil, fmt.Errorf("signing.private_key_pem: %w", err) + var priv ed25519.PrivateKey + if c.Signing.Disabled { + // Reject stale key material rather than silently ignore it. If an + // operator flips signing.disabled back to false in a later + // deployment, they should re-supply the key material at the same + // time, not rely on whatever was left behind in the YAML from a + // previous run. + if c.Signing.KeyID != "" || c.Signing.PrivateKeyPEM != "" { + return nil, errors.New("signing.disabled=true is incompatible with signing.key_id or signing.private_key_pem being set; clear both so a later re-enable is deliberate") + } + } else { + if c.Signing.KeyID == "" { + return nil, errors.New("signing.key_id is required (set signing.disabled=true to opt out, non-production only)") + } + if c.Signing.PrivateKeyPEM == "" { + return nil, errors.New("signing.private_key_pem is required (set signing.disabled=true to opt out, non-production only)") + } + var err error + priv, err = tmproto.LoadEd25519PrivateKeyPEM([]byte(c.Signing.PrivateKeyPEM)) + if err != nil { + return nil, fmt.Errorf("signing.private_key_pem: %w", err) + } } if len(c.Providers) == 0 { return nil, errors.New("at least one provider is required") diff --git a/modules/adcontextprotocol/tmp/config_test.go b/modules/adcontextprotocol/tmp/config_test.go index ca00e6e7c6f..ed2b2269583 100644 --- a/modules/adcontextprotocol/tmp/config_test.go +++ b/modules/adcontextprotocol/tmp/config_test.go @@ -149,6 +149,40 @@ func TestValidated_MissingSigningKey(t *testing.T) { } } +func TestValidated_SigningDisabledAllowsEmptyKey(t *testing.T) { + cfg := validConfig(t) + cfg.Signing.KeyID = "" + cfg.Signing.PrivateKeyPEM = "" + cfg.Signing.Disabled = true + priv, err := cfg.validated() + if err != nil { + t.Fatalf("expected valid config with signing disabled, got %v", err) + } + if priv != nil { + t.Errorf("expected nil private key when signing disabled; got %v", priv) + } +} + +func TestValidated_SigningDisabledRejectsStaleKeyID(t *testing.T) { + cfg := validConfig(t) + cfg.Signing.Disabled = true + cfg.Signing.PrivateKeyPEM = "" + // KeyID left set from validConfig — stale material next to disabled=true. + if _, err := cfg.validated(); err == nil { + t.Fatal("expected error when signing.disabled=true but key_id is still populated") + } +} + +func TestValidated_SigningDisabledRejectsStalePEM(t *testing.T) { + cfg := validConfig(t) + cfg.Signing.Disabled = true + cfg.Signing.KeyID = "" + // PrivateKeyPEM left set from validConfig — stale material next to disabled=true. + if _, err := cfg.validated(); err == nil { + t.Fatal("expected error when signing.disabled=true but private_key_pem is still populated") + } +} + func TestValidated_LatLongPrecisionCapped(t *testing.T) { cfg := validConfig(t) cfg.Masking.Enabled = true diff --git a/modules/adcontextprotocol/tmp/iso3166.go b/modules/adcontextprotocol/tmp/iso3166.go new file mode 100644 index 00000000000..6ebe097825f --- /dev/null +++ b/modules/adcontextprotocol/tmp/iso3166.go @@ -0,0 +1,106 @@ +package tmp + +import "strings" + +// normalizeCountryToAlpha2 converts a country code to its ISO 3166-1 +// alpha-2 form for TMP wire emission. Accepts: +// +// - 2-char input: uppercased and returned as-is (already alpha-2) +// - 3-char input: looked up in iso3166Alpha3ToAlpha2 and returned as +// alpha-2 when the code is recognized +// - anything else: returned "" so the caller omits the field +// +// The 3-char path exists because OpenRTB `device.geo.country` is defined +// as ISO 3166-1 alpha-3 (`"USA"`), whereas the TMP wire's `country` field +// is alpha-2 (`"US"`, per adcp-go's tmproto validator). A spec-compliant +// OpenRTB caller would otherwise produce a hard 400 at the verifier. +// +// The first-two-characters heuristic is deliberately NOT used — it maps +// `AUT` (Austria) to `AU` (Australia), `IRL` (Ireland) to `IR` (Iran), +// `PRT` (Portugal) to `PR` (Puerto Rico), and other silent +// misclassifications. Only the lookup table below is trusted. +func normalizeCountryToAlpha2(in string) string { + s := strings.ToUpper(strings.TrimSpace(in)) + switch len(s) { + case 0: + return "" + case 2: + if isASCIILettersOnly(s) { + return s + } + return "" + case 3: + if a2, ok := iso3166Alpha3ToAlpha2[s]; ok { + return a2 + } + return "" + default: + return "" + } +} + +func isASCIILettersOnly(s string) bool { + for i := 0; i < len(s); i++ { + c := s[i] + if c < 'A' || c > 'Z' { + return false + } + } + return true +} + +// iso3166Alpha3ToAlpha2 maps every ISO 3166-1 alpha-3 code to its +// corresponding alpha-2. Sourced from the ISO 3166-1 register; this +// list changes rarely and is safe to embed inline. +var iso3166Alpha3ToAlpha2 = map[string]string{ + "AFG": "AF", "ALA": "AX", "ALB": "AL", "DZA": "DZ", "ASM": "AS", + "AND": "AD", "AGO": "AO", "AIA": "AI", "ATA": "AQ", "ATG": "AG", + "ARG": "AR", "ARM": "AM", "ABW": "AW", "AUS": "AU", "AUT": "AT", + "AZE": "AZ", "BHS": "BS", "BHR": "BH", "BGD": "BD", "BRB": "BB", + "BLR": "BY", "BEL": "BE", "BLZ": "BZ", "BEN": "BJ", "BMU": "BM", + "BTN": "BT", "BOL": "BO", "BES": "BQ", "BIH": "BA", "BWA": "BW", + "BVT": "BV", "BRA": "BR", "IOT": "IO", "BRN": "BN", "BGR": "BG", + "BFA": "BF", "BDI": "BI", "CPV": "CV", "KHM": "KH", "CMR": "CM", + "CAN": "CA", "CYM": "KY", "CAF": "CF", "TCD": "TD", "CHL": "CL", + "CHN": "CN", "CXR": "CX", "CCK": "CC", "COL": "CO", "COM": "KM", + "COG": "CG", "COD": "CD", "COK": "CK", "CRI": "CR", "CIV": "CI", + "HRV": "HR", "CUB": "CU", "CUW": "CW", "CYP": "CY", "CZE": "CZ", + "DNK": "DK", "DJI": "DJ", "DMA": "DM", "DOM": "DO", "ECU": "EC", + "EGY": "EG", "SLV": "SV", "GNQ": "GQ", "ERI": "ER", "EST": "EE", + "SWZ": "SZ", "ETH": "ET", "FLK": "FK", "FRO": "FO", "FJI": "FJ", + "FIN": "FI", "FRA": "FR", "GUF": "GF", "PYF": "PF", "ATF": "TF", + "GAB": "GA", "GMB": "GM", "GEO": "GE", "DEU": "DE", "GHA": "GH", + "GIB": "GI", "GRC": "GR", "GRL": "GL", "GRD": "GD", "GLP": "GP", + "GUM": "GU", "GTM": "GT", "GGY": "GG", "GIN": "GN", "GNB": "GW", + "GUY": "GY", "HTI": "HT", "HMD": "HM", "VAT": "VA", "HND": "HN", + "HKG": "HK", "HUN": "HU", "ISL": "IS", "IND": "IN", "IDN": "ID", + "IRN": "IR", "IRQ": "IQ", "IRL": "IE", "IMN": "IM", "ISR": "IL", + "ITA": "IT", "JAM": "JM", "JPN": "JP", "JEY": "JE", "JOR": "JO", + "KAZ": "KZ", "KEN": "KE", "KIR": "KI", "PRK": "KP", "KOR": "KR", + "KWT": "KW", "KGZ": "KG", "LAO": "LA", "LVA": "LV", "LBN": "LB", + "LSO": "LS", "LBR": "LR", "LBY": "LY", "LIE": "LI", "LTU": "LT", + "LUX": "LU", "MAC": "MO", "MDG": "MG", "MWI": "MW", "MYS": "MY", + "MDV": "MV", "MLI": "ML", "MLT": "MT", "MHL": "MH", "MTQ": "MQ", + "MRT": "MR", "MUS": "MU", "MYT": "YT", "MEX": "MX", "FSM": "FM", + "MDA": "MD", "MCO": "MC", "MNG": "MN", "MNE": "ME", "MSR": "MS", + "MAR": "MA", "MOZ": "MZ", "MMR": "MM", "NAM": "NA", "NRU": "NR", + "NPL": "NP", "NLD": "NL", "NCL": "NC", "NZL": "NZ", "NIC": "NI", + "NER": "NE", "NGA": "NG", "NIU": "NU", "NFK": "NF", "MKD": "MK", + "MNP": "MP", "NOR": "NO", "OMN": "OM", "PAK": "PK", "PLW": "PW", + "PSE": "PS", "PAN": "PA", "PNG": "PG", "PRY": "PY", "PER": "PE", + "PHL": "PH", "PCN": "PN", "POL": "PL", "PRT": "PT", "PRI": "PR", + "QAT": "QA", "REU": "RE", "ROU": "RO", "RUS": "RU", "RWA": "RW", + "BLM": "BL", "SHN": "SH", "KNA": "KN", "LCA": "LC", "MAF": "MF", + "SPM": "PM", "VCT": "VC", "WSM": "WS", "SMR": "SM", "STP": "ST", + "SAU": "SA", "SEN": "SN", "SRB": "RS", "SYC": "SC", "SLE": "SL", + "SGP": "SG", "SXM": "SX", "SVK": "SK", "SVN": "SI", "SLB": "SB", + "SOM": "SO", "ZAF": "ZA", "SGS": "GS", "SSD": "SS", "ESP": "ES", + "LKA": "LK", "SDN": "SD", "SUR": "SR", "SJM": "SJ", "SWE": "SE", + "CHE": "CH", "SYR": "SY", "TWN": "TW", "TJK": "TJ", "TZA": "TZ", + "THA": "TH", "TLS": "TL", "TGO": "TG", "TKL": "TK", "TON": "TO", + "TTO": "TT", "TUN": "TN", "TUR": "TR", "TKM": "TM", "TCA": "TC", + "TUV": "TV", "UGA": "UG", "UKR": "UA", "ARE": "AE", "GBR": "GB", + "USA": "US", "UMI": "UM", "URY": "UY", "UZB": "UZ", "VUT": "VU", + "VEN": "VE", "VNM": "VN", "VGB": "VG", "VIR": "VI", "WLF": "WF", + "ESH": "EH", "YEM": "YE", "ZMB": "ZM", "ZWE": "ZW", +} diff --git a/modules/adcontextprotocol/tmp/iso3166_test.go b/modules/adcontextprotocol/tmp/iso3166_test.go new file mode 100644 index 00000000000..60e9169498e --- /dev/null +++ b/modules/adcontextprotocol/tmp/iso3166_test.go @@ -0,0 +1,51 @@ +package tmp + +import "testing" + +func TestNormalizeCountryToAlpha2(t *testing.T) { + cases := []struct { + in, want string + }{ + // Already alpha-2 → pass-through (uppercased). + {"US", "US"}, + {"us", "US"}, + {" gb ", "GB"}, + + // Common alpha-3 → alpha-2 lookups. + {"USA", "US"}, + {"GBR", "GB"}, + {"CAN", "CA"}, + {"DEU", "DE"}, + {"FRA", "FR"}, + {"JPN", "JP"}, + {"BRA", "BR"}, + {"IND", "IN"}, + {"ARE", "AE"}, + {"CHE", "CH"}, + + // The traps the first-two-characters heuristic would silently + // misclassify. These MUST round-trip through the lookup table. + {"AUT", "AT"}, // Austria — first-two would give AU (Australia) + {"IRL", "IE"}, // Ireland — first-two would give IR (Iran) + {"PRT", "PT"}, // Portugal — first-two would give PR (Puerto Rico) + {"KOR", "KR"}, // Korea (South) — first-two would give KO (unassigned) + {"CHN", "CN"}, // China — first-two would give CH (Switzerland) + {"SVK", "SK"}, // Slovakia — first-two would give SV (unassigned) + {"SWZ", "SZ"}, // Eswatini — first-two would give SW (unassigned) + + // Rejected shapes. + {"", ""}, + {"U", ""}, // 1 char + {"US1", ""}, // 3 chars but not letters + {"USSR", ""}, // historical, no ISO code + {"UNITED", ""}, // 6 chars + {"12", ""}, // 2 chars but not letters + {"unknown3", ""}, // 8 chars + } + for _, tc := range cases { + got := normalizeCountryToAlpha2(tc.in) + if got != tc.want { + t.Errorf("normalizeCountryToAlpha2(%q) = %q; want %q", tc.in, got, tc.want) + } + } +} diff --git a/modules/adcontextprotocol/tmp/module.go b/modules/adcontextprotocol/tmp/module.go index 34159cad0fd..3f8529bc2ca 100644 --- a/modules/adcontextprotocol/tmp/module.go +++ b/modules/adcontextprotocol/tmp/module.go @@ -16,6 +16,7 @@ import ( "github.com/adcontextprotocol/adcp-go/tmproto" "github.com/prebid/prebid-server/v4/hooks/hookstage" + "github.com/prebid/prebid-server/v4/logger" "github.com/prebid/prebid-server/v4/modules/moduledeps" "github.com/prebid/prebid-server/v4/util/jsonutil" ) @@ -32,9 +33,14 @@ func Builder(raw json.RawMessage, deps moduledeps.ModuleDeps) (any, error) { return nil, fmt.Errorf("adcontextprotocol.tmp: invalid config: %w", err) } - signer, err := tmproto.NewSigner(cfg.Signing.KeyID, privKey) - if err != nil { - return nil, fmt.Errorf("adcontextprotocol.tmp: signer: %w", err) + var signer *tmproto.Signer + if cfg.Signing.Disabled { + logger.Warnf("adcontextprotocol.tmp: signing.disabled=true — outbound TMP requests will be sent WITHOUT X-AdCP-Signature / X-AdCP-Key-Id headers. DO NOT USE IN PRODUCTION. Intended for pre-production rollout only, where the verifier accepts unsigned requests (TMP_ALLOW_UNSIGNED=true).") + } else { + signer, err = tmproto.NewSigner(cfg.Signing.KeyID, privKey) + if err != nil { + return nil, fmt.Errorf("adcontextprotocol.tmp: signer: %w", err) + } } // No client-level Timeout: per-call deadlines come from context so that diff --git a/modules/adcontextprotocol/tmp/provider_client.go b/modules/adcontextprotocol/tmp/provider_client.go index b62f5dfa9fc..ebddaa99ee7 100644 --- a/modules/adcontextprotocol/tmp/provider_client.go +++ b/modules/adcontextprotocol/tmp/provider_client.go @@ -14,10 +14,16 @@ import ( // callContext signs and POSTs a ContextMatch request to the provider's context // endpoint. Signatures are computed per-provider-endpoint per the TMP spec. +// When the module is configured with signing.disabled=true (m.signer==nil) +// the request goes out unsigned — the doTMP path omits both signature +// headers. func (m *Module) callContext(ctx context.Context, p ProviderConfig, req *tmproto.ContextMatchRequest) (*tmproto.ContextMatchResponse, error) { - epoch := tmproto.CurrentEpoch() - endpoint := tmproto.NormalizeProviderEndpointURL(p.ContextURL) - sig := m.signer.SignContextMatch(req, endpoint, epoch) + var sig string + if m.signer != nil { + epoch := tmproto.CurrentEpoch() + endpoint := tmproto.NormalizeProviderEndpointURL(p.ContextURL) + sig = m.signer.SignContextMatch(req, endpoint, epoch) + } raw, err := jsonutil.Marshal(req) if err != nil { @@ -42,12 +48,19 @@ func (m *Module) callContext(ctx context.Context, p ProviderConfig, req *tmproto // The provider→router hop returns ProviderIdentityMatchResponse (eligibility // plus provider-local TMPX chunks). This module IS the router — publisher- // facing shape reassembly happens locally via the TmpxMacroMapping config. +// +// When m.signer==nil (signing.disabled=true) the request is sent unsigned; +// doTMP omits the signature headers entirely. func (m *Module) callIdentity(ctx context.Context, p ProviderConfig, req *tmproto.IdentityMatchRequest) (*tmproto.ProviderIdentityMatchResponse, error) { - epoch := tmproto.CurrentEpoch() - endpoint := tmproto.NormalizeProviderEndpointURL(p.IdentityURL) - sig, err := m.signer.SignIdentityMatch(req, endpoint, epoch) - if err != nil { - return nil, fmt.Errorf("identity sign: %w", err) + var sig string + if m.signer != nil { + epoch := tmproto.CurrentEpoch() + endpoint := tmproto.NormalizeProviderEndpointURL(p.IdentityURL) + var err error + sig, err = m.signer.SignIdentityMatch(req, endpoint, epoch) + if err != nil { + return nil, fmt.Errorf("identity sign: %w", err) + } } raw, err := jsonutil.Marshal(req) @@ -113,8 +126,10 @@ func (m *Module) doTMP(ctx context.Context, url string, body []byte, signature s return nil, err } httpReq.Header.Set("Content-Type", "application/json") - httpReq.Header.Set(tmproto.HeaderTMPSignature, signature) - httpReq.Header.Set(tmproto.HeaderTMPKeyID, m.signer.KeyID) + if m.signer != nil { + httpReq.Header.Set(tmproto.HeaderTMPSignature, signature) + httpReq.Header.Set(tmproto.HeaderTMPKeyID, m.signer.KeyID) + } resp, err := m.http.Do(httpReq) if err != nil {