diff --git a/adcp/schemas/.bundle-sha256 b/adcp/schemas/.bundle-sha256 index 5014b0b..85a6be4 100644 --- a/adcp/schemas/.bundle-sha256 +++ b/adcp/schemas/.bundle-sha256 @@ -1 +1 @@ -7eecf9826aab38b6574cb89e5e99cc80ee860ecda04013144620dcd831633d5f +e1894a8222529bafc34a8e5d45b395e1b4079de82238416cc753fa71940dfc5d diff --git a/adcp/schemas/VERSION b/adcp/schemas/VERSION index fd2a018..94ff29c 100644 --- a/adcp/schemas/VERSION +++ b/adcp/schemas/VERSION @@ -1 +1 @@ -3.1.0 +3.1.1 diff --git a/adcp/types.go b/adcp/types.go index b0a0633..8ad5e09 100644 --- a/adcp/types.go +++ b/adcp/types.go @@ -84,6 +84,7 @@ type MediaBuyCapabilities struct { ReportingDeliveryMethods []string `json:"reporting_delivery_methods,omitempty"` OfflineDeliveryProtocols []string `json:"offline_delivery_protocols,omitempty"` SupportsProposals *bool `json:"supports_proposals,omitempty"` + GovernanceAware *bool `json:"governance_aware,omitempty"` PropagationSurfaces []string `json:"propagation_surfaces,omitempty"` CreativeApprovalMode string `json:"creative_approval_mode,omitempty"` Features map[string]any `json:"features,omitempty"` diff --git a/adcp/types_gen.go b/adcp/types_gen.go index e07a5b1..db0df0f 100644 --- a/adcp/types_gen.go +++ b/adcp/types_gen.go @@ -1,5 +1,5 @@ // Code generated by generate.py from AdCP JSON schemas. DO NOT EDIT. -// AdCP schema version: 3.1.0 +// AdCP schema version: 3.1.1 // Source: https://github.com/adcontextprotocol/adcp/tree/main/static/schemas/source package adcp @@ -9847,11 +9847,11 @@ type GetProductsRequest struct { Account *AccountReference `json:"account,omitempty"` // Account for product lookup. Returns products with pricing specific to this PreferredDeliveryTypes []DeliveryType `json:"preferred_delivery_types,omitempty"` // Delivery types the buyer prefers, in priority order. Unlike Filters *ProductFilters `json:"filters,omitempty"` - PropertyList *PropertyListRef `json:"property_list,omitempty"` // [AdCP 3.0] Reference to an externally managed property list. When provided - Fields []string `json:"fields,omitempty"` // Specific product fields to include in the response. When omitted, all fields - TimeBudget *Duration `json:"time_budget,omitempty"` // Maximum time the buyer will commit to this request. The seller returns the - PushNotificationConfig *PushNotificationConfig `json:"push_notification_config,omitempty"` // Optional webhook configuration for async terminal completion/failure - Pagination *PaginationRequest `json:"pagination,omitempty"` + PropertyList *PropertyListRef `json:"property_list,omitempty"` // [AdCP 3.0] Reference to an externally managed property list. When provided + Fields []string `json:"fields,omitempty"` // Specific product fields to include in the response. When omitted, all fields + TimeBudget *Duration `json:"time_budget,omitempty"` // Maximum time the buyer will commit to this request. The seller returns the + PushNotificationConfig *PushNotificationConfig `json:"push_notification_config,omitempty"` // Optional webhook configuration for async terminal completion/failure + Pagination *PaginationRequest `json:"pagination,omitempty"` // Cursor-based pagination controls for get_products. Valid in all buying modes. IfWholesaleFeedVersion string `json:"if_wholesale_feed_version,omitempty"` // Opaque wholesale_feed_version token returned by a prior wholesale-mode IfPricingVersion string `json:"if_pricing_version,omitempty"` // Opaque pricing_version token from a prior get_products response. MUST only be Context any `json:"context,omitempty"` @@ -9883,12 +9883,12 @@ type GetProductsResponse struct { RefinementApplied []GetProductsRefinementAppliedItem `json:"refinement_applied,omitempty"` // Seller's response to each change request in the refine array, matched by Incomplete []GetProductsIncompleteItem `json:"incomplete,omitempty"` // Declares what the seller could not finish within the buyer's time_budget or FilterDiagnostics *GetProductsFilterDiagnostics `json:"filter_diagnostics,omitempty"` // Optional non-fatal diagnostic block describing how the request's `filters` - Pagination *PaginationResponse `json:"pagination,omitempty"` - WholesaleFeedVersion string `json:"wholesale_feed_version,omitempty"` // Opaque token representing the version of the wholesale product feed state used - PricingVersion string `json:"pricing_version,omitempty"` // Opaque token representing the version of the pricing layer, including product - CacheScope string `json:"cache_scope,omitempty"` // Declares whether the wholesale_feed_version and pricing_version on this - Unchanged *bool `json:"unchanged,omitempty"` // Present and `true` ONLY on wholesale-mode responses when the request carried - Sandbox *bool `json:"sandbox,omitempty"` // When true, this response contains simulated data from sandbox mode. + Pagination *PaginationResponse `json:"pagination,omitempty"` // Cursor metadata for paginated get_products responses. In brief/refine mode + WholesaleFeedVersion string `json:"wholesale_feed_version,omitempty"` // Opaque token representing the version of the wholesale product feed state used + PricingVersion string `json:"pricing_version,omitempty"` // Opaque token representing the version of the pricing layer, including product + CacheScope string `json:"cache_scope,omitempty"` // Declares whether the wholesale_feed_version and pricing_version on this + Unchanged *bool `json:"unchanged,omitempty"` // Present and `true` ONLY on wholesale-mode responses when the request carried + Sandbox *bool `json:"sandbox,omitempty"` // When true, this response contains simulated data from sandbox mode. Ext any `json:"ext,omitempty"` } diff --git a/internal/generate/go-overlays.json b/internal/generate/go-overlays.json index e5591e9..487963c 100644 --- a/internal/generate/go-overlays.json +++ b/internal/generate/go-overlays.json @@ -46,7 +46,8 @@ "Offer.creative_manifest": {"type": "json.RawMessage", "pointer": true}, "Offer.price": {"type": "OfferPrice", "pointer": true}, "ProviderRegistration.status": {"type": "ProviderStatus"}, - "ErrorResponse.code": {"type": "ErrorCode"} + "ErrorResponse.code": {"type": "ErrorCode"}, + "IdentityMatchResponse.tmpx_providers": {"type": "map[string]TmpxProviderEntry"} }, "refs": { "/schemas/latest/core/property-id.json": "string", diff --git a/internal/generate/schema.go b/internal/generate/schema.go index fc772c1..7b3cb44 100644 --- a/internal/generate/schema.go +++ b/internal/generate/schema.go @@ -192,7 +192,7 @@ func LoadSchemas(schemaDir, enumDir, mergeDir, overlayPath string) (*IR, error) // Infer the $id or construct ref path from filename. refPath := s.ID if refPath == "" { - refPath = "/schemas/tmp/" + e.Name() + refPath = "/schemas/trusted-match/" + e.Name() } ctx.structReg[refPath] = goName } diff --git a/router/router.go b/router/router.go index 98ba3b6..3aa86e8 100644 --- a/router/router.go +++ b/router/router.go @@ -620,22 +620,31 @@ func mergeContextResponses(requestID string, responses []contextResult, logger * // same `package_id` in multiple providers' eligible lists — we log a warning // naming all reporting providers and emit the union (the spec's "must be in // both" rule collapses to union when only yes-responses are observable). -// TMPX: collected from whichever provider returned it (last wins; in -// practice only one provider mints the token). +// +// TMPX collection per the spec §"TMPX collection": +// - Each agent's TmpxMacros[] is folded into tmpx_providers[provider_id] so +// per-provider attribution survives the fan-out. +// - The legacy singular `tmpx` field stays populated on the merged response +// for back-compat with consumers that haven't moved to tmpx_providers +// (deprecated, removed in 4.0). Source order: prefer the first provider's +// first macro value when TmpxMacros[] is present; otherwise fall back to +// the agent's legacy `tmpx` field (legacy-only agents during transition). +// - The router MUST NOT carry tmpx_macros[] at the root of the outbound +// response — that field is the agent → router carrier only; leaking it +// alongside tmpx_providers would give the publisher no schema signal for +// which to read. func mergeIdentityResponses(requestID string, providerIDs []string, responses []*tmproto.IdentityMatchResponse, logger *slog.Logger) *tmproto.IdentityMatchResponse { eligibleSet := make(map[string]struct{}) pkgProviders := make(map[string][]string) // package_id -> distinct provider IDs that listed it providerRepeats := make(map[string]map[string]bool) // package_id -> set of providers that repeated it within their own response minServeWindowSec := -1 - var tmpx string + var legacyTmpx string + tmpxProviders := make(map[string]tmproto.TmpxProviderEntry) for i, resp := range responses { if resp == nil { continue } - if resp.Tmpx != "" { - tmpx = resp.Tmpx - } if minServeWindowSec < 0 || resp.ServeWindowSec < minServeWindowSec { minServeWindowSec = resp.ServeWindowSec } @@ -643,6 +652,26 @@ func mergeIdentityResponses(requestID string, providerIDs []string, responses [] if i < len(providerIDs) { providerID = providerIDs[i] } + // TMPX: prefer the new TmpxMacros[] carrier — collect into the + // per-provider map. Skip empty providerID entries (defensive: a + // fan-out with mis-aligned parallel slices would otherwise stash + // macros under "", which any consumer would treat as garbage). + if len(resp.TmpxMacros) > 0 && providerID != "" { + tmpxProviders[providerID] = tmproto.TmpxProviderEntry{ + Macros: append([]tmproto.TmpxMacro(nil), resp.TmpxMacros...), + } + if legacyTmpx == "" { + legacyTmpx = resp.TmpxMacros[0].Value + } + } else if resp.Tmpx != "" { + // Legacy-only agent during transition — preserve in legacy + // carrier so single-string consumers keep working. No + // tmpx_providers entry: the router does not synthesize names + // from registration metadata in this version. + if legacyTmpx == "" { + legacyTmpx = resp.Tmpx + } + } // Track DISTINCT providers per package_id and remember if any single // provider repeats a package_id within its own response — eligible // arrives raw off the wire with no dedup, so a within-provider repeat @@ -719,7 +748,10 @@ func mergeIdentityResponses(requestID string, providerIDs []string, responses [] RequestID: requestID, EligiblePackageIDs: eligible, ServeWindowSec: minServeWindowSec, - Tmpx: tmpx, + Tmpx: legacyTmpx, + } + if len(tmpxProviders) > 0 { + merged.TmpxProviders = tmpxProviders } return merged } diff --git a/router/router_test.go b/router/router_test.go index bf4d233..f5a49f2 100644 --- a/router/router_test.go +++ b/router/router_test.go @@ -162,6 +162,112 @@ func TestMergeIdentityResponses(t *testing.T) { assert.Equal(t, 300, merged.ServeWindowSec) } +// TestMergeIdentityResponses_TmpxProvidersFromNewShape verifies that each +// agent's TmpxMacros[] is folded into the merged TmpxProviders map keyed by +// provider_id, preserving per-provider attribution across fan-out. Legacy +// `tmpx` stays populated for back-compat with consumers that haven't moved +// to the new shape, sourced from the first provider's first macro value. +func TestMergeIdentityResponses_TmpxProvidersFromNewShape(t *testing.T) { + r1 := &tmproto.IdentityMatchResponse{ + EligiblePackageIDs: []string{"pkg-1"}, + ServeWindowSec: 300, + TmpxMacros: []tmproto.TmpxMacro{ + {Name: "PIN_TMPX_1", Value: "k1.alpha-value"}, + }, + } + r2 := &tmproto.IdentityMatchResponse{ + EligiblePackageIDs: []string{"pkg-2"}, + ServeWindowSec: 300, + TmpxMacros: []tmproto.TmpxMacro{ + {Name: "NOVA_TMPX_1", Value: "k2.beta-value"}, + }, + } + + merged := mergeIdentityResponses("id-tmpx", []string{"pinnacle_id", "nova_id"}, + []*tmproto.IdentityMatchResponse{r1, r2}, nil) + + require.NotNil(t, merged.TmpxProviders, "tmpx_providers MUST be populated when any agent emitted tmpx_macros") + require.Len(t, merged.TmpxProviders, 2) + + pin, ok := merged.TmpxProviders["pinnacle_id"] + require.True(t, ok, "pinnacle_id entry must exist") + require.Len(t, pin.Macros, 1) + assert.Equal(t, "PIN_TMPX_1", pin.Macros[0].Name) + assert.Equal(t, "k1.alpha-value", pin.Macros[0].Value) + + nova, ok := merged.TmpxProviders["nova_id"] + require.True(t, ok, "nova_id entry must exist") + assert.Equal(t, "NOVA_TMPX_1", nova.Macros[0].Name) + assert.Equal(t, "k2.beta-value", nova.Macros[0].Value) + + // Legacy carrier is the first provider's first slot, so back-compat + // consumers receive a non-empty value without per-provider context. + assert.Equal(t, "k1.alpha-value", merged.Tmpx, + "legacy tmpx must mirror the first provider's first slot for back-compat") +} + +// TestMergeIdentityResponses_LegacyOnlyAgent covers a transition case: an +// agent that only emits the deprecated `tmpx` string with no TmpxMacros[]. +// The router preserves it in the legacy field but does NOT synthesize a +// tmpx_providers entry — the router doesn't have the registered macro names +// in this code path and shouldn't invent them. +func TestMergeIdentityResponses_LegacyOnlyAgent(t *testing.T) { + legacy := &tmproto.IdentityMatchResponse{ + EligiblePackageIDs: []string{"pkg-1"}, + ServeWindowSec: 300, + Tmpx: "k1.legacy-value", + } + + merged := mergeIdentityResponses("id-legacy", []string{"legacy_provider"}, + []*tmproto.IdentityMatchResponse{legacy}, nil) + + assert.Empty(t, merged.TmpxProviders, "no TmpxMacros[] emitted → no tmpx_providers entry") + assert.Equal(t, "k1.legacy-value", merged.Tmpx, "legacy carrier preserved") +} + +// TestMergeIdentityResponses_MixedShapeAgents covers a transition fan-out +// where one agent emits the new TmpxMacros[] carrier and another only emits +// the legacy `tmpx` string. The merged response MUST: +// - populate tmpx_providers with the new-shape agent's entry only +// (legacy-only agents don't get synthesized — router has no registered +// names in that path) +// - source the legacy `Tmpx` carrier from the first response that has a +// non-empty value (first-source-wins, in input order), which pins the +// back-compat behavior when responses arrive in mixed order: a +// legacy-only agent that sorts ahead does NOT lose to a new-shape agent +// that sorts behind it. The deprecated carrier is best-effort +// compatibility; tmpx_providers is the authoritative new shape. +func TestMergeIdentityResponses_MixedShapeAgents(t *testing.T) { + legacyAgent := &tmproto.IdentityMatchResponse{ + EligiblePackageIDs: []string{"pkg-1"}, + ServeWindowSec: 300, + Tmpx: "k0.legacy-string", + } + newShapeAgent := &tmproto.IdentityMatchResponse{ + EligiblePackageIDs: []string{"pkg-2"}, + ServeWindowSec: 300, + TmpxMacros: []tmproto.TmpxMacro{ + {Name: "PIN_TMPX_1", Value: "k1.new-shape-value"}, + }, + } + + merged := mergeIdentityResponses("id-mixed", + []string{"legacy_provider", "new_provider"}, + []*tmproto.IdentityMatchResponse{legacyAgent, newShapeAgent}, nil) + + require.Len(t, merged.TmpxProviders, 1, + "only the new-shape agent contributes to tmpx_providers") + entry, ok := merged.TmpxProviders["new_provider"] + require.True(t, ok) + assert.Equal(t, "k1.new-shape-value", entry.Macros[0].Value) + + // Legacy-only agent sorts ahead → its legacy string wins the legacy + // mirror, even though a later agent has a new-shape value. Pins the + // first-source-wins contract for the deprecated carrier. + assert.Equal(t, "k0.legacy-string", merged.Tmpx, + "legacy carrier is first-source-wins across mixed-shape responses") +} + // TestMergeContextResponses_DuplicatePackageID covers the dedup-warn path the // router-architecture spec calls out: same package_id from two providers MUST // keep the first response and SHOULD log a warning naming both providers. diff --git a/targeting/identityagent/config.go b/targeting/identityagent/config.go index eb43d20..395cc0a 100644 --- a/targeting/identityagent/config.go +++ b/targeting/identityagent/config.go @@ -116,6 +116,17 @@ type TMPXConfig struct { EncryptJWKSTTL time.Duration Country string Priority string + + // MacroNames is the ordered list of ad-server macro slot names this + // provider's TMPX response fills, matching the provider's registered + // `tmpx_macros` (provider-registration.json). The sealer pairs the + // sealed token with these names to populate IdentityMatchResponse's + // `tmpx_macros[]`. When empty the response carries only the legacy + // singular `tmpx` field (deprecated, removed in 4.0). Capped at 2 by + // the spec; multi-chunk encoding is not yet implemented — when + // configured with more than one name only the first slot is filled, + // matching the single-slot deployment shape. + MacroNames []string } // LiveRampSidecarConfig optionally enables calls to the Scope3 LiveRamp @@ -410,6 +421,7 @@ func LoadConfigFromEnv() (Config, error) { EncryptJWKSTTL: jwksTTL, Country: os.Getenv("TMPX_COUNTRY"), Priority: os.Getenv("TMPX_PRIORITY"), + MacroNames: parseTmpxMacroNames(os.Getenv("TMPX_MACRO_NAMES")), }, LiveRamp: LiveRampSidecarConfig{ URL: os.Getenv("LIVERAMP_SIDECAR_URL"), @@ -645,6 +657,35 @@ func lookupString(name, def string) string { return def } +// parseTmpxMacroNames splits TMPX_MACRO_NAMES (comma-separated, e.g. +// `S3_TMPX` or `S3_TMPX_1,S3_TMPX_2`) into the ordered slot list emitted on +// IdentityMatchResponse.tmpx_macros[]. Empty / whitespace-only values yield +// nil, which keeps the legacy single-`tmpx`-string emission shape — no new +// behavior until the env var is set. The spec caps the registered list at 2 +// (provider-registration.json `tmpx_macros.maxItems`); enforcement of that +// happens at registration time, not here — this parser is permissive so an +// operator sees the misconfig surface as a downstream schema-validation +// error rather than a startup panic. +func parseTmpxMacroNames(raw string) []string { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil + } + parts := strings.Split(raw, ",") + names := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p == "" { + continue + } + names = append(names, p) + } + if len(names) == 0 { + return nil + } + return names +} + func lookupInt(name string, def int) (int, error) { v := os.Getenv(name) if v == "" { diff --git a/targeting/identityagent/handler.go b/targeting/identityagent/handler.go index 81bd54b..36e0e6c 100644 --- a/targeting/identityagent/handler.go +++ b/targeting/identityagent/handler.go @@ -194,7 +194,17 @@ func (h *identityHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { "request_id", req.RequestID, "error", terr) h.recorder.StageOutcome(ctx, StageTMPX, OutcomeError) } else if token != "" { + // Legacy single-string carrier — keep populated for back-compat + // with consumers that haven't moved to tmpx_providers. Deprecated; + // removed in 4.0 per the spec. resp.Tmpx = token + // New shape: ordered macro/value pairs the router collects into + // tmpx_providers[provider_id]. Emitted only when the operator has + // declared the provider's registered macro slot names via + // TMPX_MACRO_NAMES; otherwise we stay on the legacy carrier. + if entry, ok := h.tmpx.MacroEntry(token); ok { + resp.TmpxMacros = []tmproto.TmpxMacro{entry} + } h.recorder.StageOutcome(ctx, StageTMPX, OutcomePass) } h.recorder.StageDuration(ctx, StageTMPX, time.Since(tmpxStart)) diff --git a/targeting/identityagent/tmpx.go b/targeting/identityagent/tmpx.go index 099c9f9..45026b8 100644 --- a/targeting/identityagent/tmpx.go +++ b/targeting/identityagent/tmpx.go @@ -109,6 +109,17 @@ type TMPXSealer struct { country string encStore tmpxRecipientResolver + // macroNames is the ordered list of ad-server macro slot names this + // provider's sealed token fills on IdentityMatchResponse.tmpx_macros[]. + // Sourced from TMPXConfig.MacroNames (env: TMPX_MACRO_NAMES) and MUST + // match the provider's registered tmpx_macros list in + // provider-registration.json. When empty the response carries only the + // legacy singular `tmpx` field (deprecated, removed in 4.0). The spec + // caps the registered list at 2; multi-chunk encoding is not yet + // implemented in this sealer — a single-slot deployment is the only + // shape this version emits, and additional slot names are ignored. + macroNames []string + // priority is the explicit per-spec priority ordering used when the // resolved identities exceed the 255-byte wire budget. Entries earlier // in the slice rank higher; entries whose UIDType is absent are @@ -208,16 +219,49 @@ func NewTMPXSealer(runCtx context.Context, cfg TMPXConfig, lrClient LiveRampSide } decoders := buildTmpxDecoders(tmpxdecoders.RegistryOptions{LiveRampClient: decoderAdapter}) logDecoderLayout(logger, cfg.Country, decoders, order) + warnIfMultiSlotIgnored(logger, cfg.MacroNames) return &TMPXSealer{ - country: cfg.Country, - encStore: store, - priority: order, - decoders: decoders, - logger: logger, - recorder: recorder, + country: cfg.Country, + encStore: store, + macroNames: append([]string(nil), cfg.MacroNames...), + priority: order, + decoders: decoders, + logger: logger, + recorder: recorder, }, nil } +// warnIfMultiSlotIgnored surfaces the configuration/implementation mismatch +// when an operator declares more than one TMPX_MACRO_NAMES slot. Multi-chunk +// encoding splits a single sealed token across multiple ad-server slots; the +// splitter (and the matching reassembler on the receiver side) is deferred +// until production deployments actually exceed the 255-char single-slot +// budget. Until then MacroEntry only fills macroNames[0] — log loudly so an +// operator who expects two-slot emission sees that it's silently single-slot +// today rather than discovering it at trafficking time. +func warnIfMultiSlotIgnored(logger *slog.Logger, names []string) { + if logger == nil || len(names) <= 1 { + return + } + logger.Warn("TMPX_MACRO_NAMES configured with multiple slots but multi-chunk encoding is not implemented; only the first slot will be filled on responses", + "active_slot", names[0], + "ignored_slots", append([]string(nil), names[1:]...), + ) +} + +// MacroEntry returns the {name, value} pair that fills the provider's first +// registered macro slot for the given sealed token, or (zero, false) when no +// macro names are configured. Single-slot is the only emission shape today; +// multi-chunk splitting across more than one registered name is deferred +// until production deployments actually exceed the 255-char ad-server slot +// budget. +func (s *TMPXSealer) MacroEntry(token string) (tmproto.TmpxMacro, bool) { + if s == nil || token == "" || len(s.macroNames) == 0 { + return tmproto.TmpxMacro{}, false + } + return tmproto.TmpxMacro{Name: s.macroNames[0], Value: token}, true +} + // log returns the sealer's logger, falling back to slog.Default() when none // was configured. Tests that construct TMPXSealer directly without a // logger still get sensible output. diff --git a/targeting/identityagent/tmpx_test.go b/targeting/identityagent/tmpx_test.go index 2199e5b..f36e8ef 100644 --- a/targeting/identityagent/tmpx_test.go +++ b/targeting/identityagent/tmpx_test.go @@ -1,11 +1,13 @@ package identityagent import ( + "bytes" "context" "crypto/ecdh" "crypto/rand" "encoding/base64" "encoding/json" + "log/slog" "net/http" "net/http/httptest" "strings" @@ -22,6 +24,34 @@ import ( const testNullifier = "0x" + "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff" +// MacroEntry pairs a sealed token with the provider's first registered slot +// name; both inputs must be non-empty for a useful pair to come out. +func TestMacroEntry_EmitsWhenConfigured(t *testing.T) { + s := &TMPXSealer{macroNames: []string{"S3_TMPX"}} + entry, ok := s.MacroEntry("k1.token") + require.True(t, ok) + assert.Equal(t, "S3_TMPX", entry.Name) + assert.Equal(t, "k1.token", entry.Value) +} + +// MacroEntry returns (zero, false) on three independently-disabling +// conditions: nil receiver, empty token, no registered macro names. Without +// either side the pair is meaningless, so the response should fall back to +// the legacy single-`tmpx` carrier. +func TestMacroEntry_NotEmittedWhenDisabled(t *testing.T) { + var nilSealer *TMPXSealer + _, ok := nilSealer.MacroEntry("k1.token") + assert.False(t, ok, "nil sealer must not produce a macro entry") + + noMacros := &TMPXSealer{} + _, ok = noMacros.MacroEntry("k1.token") + assert.False(t, ok, "sealer without registered macro names must not produce a macro entry") + + noToken := &TMPXSealer{macroNames: []string{"S3_TMPX"}} + _, ok = noToken.MacroEntry("") + assert.False(t, ok, "empty token must not produce a macro entry") +} + func TestVerifiedIdentityEntries_EncodesNullifier(t *testing.T) { cfg := &TMPXSealer{} entries := cfg.verifiedIdentityEntries(t.Context(), []targeting.VerifiedIdentity{ @@ -113,6 +143,44 @@ func newFakeResolver(t *testing.T, kid string) *fakeRecipientResolver { } } +// TestWarnIfMultiSlotIgnored pins the operator-visibility contract for the +// multi-slot-config-but-single-slot-emission case. The wire shape accepts up +// to two slots per provider; the sealer currently fills only the first. +// An operator who configures both expecting multi-chunk emission should see +// a startup warning naming the active slot and the ignored ones, not +// discover the gap at trafficking time. +func TestWarnIfMultiSlotIgnored(t *testing.T) { + t.Run("zero or one slot does not warn", func(t *testing.T) { + for _, names := range [][]string{nil, {}, {"S3_TMPX"}} { + var buf bytes.Buffer + logger := slog.New(slog.NewJSONHandler(&buf, nil)) + warnIfMultiSlotIgnored(logger, names) + assert.Empty(t, buf.String(), "single/zero slots are the supported shape — no warning expected (input: %v)", names) + } + }) + + t.Run("two-plus slots warn naming active and ignored", func(t *testing.T) { + var buf bytes.Buffer + logger := slog.New(slog.NewJSONHandler(&buf, nil)) + warnIfMultiSlotIgnored(logger, []string{"PIN_TMPX_1", "PIN_TMPX_2", "PIN_TMPX_3"}) + + got := buf.String() + assert.Contains(t, got, `"level":"WARN"`) + assert.Contains(t, got, "multi-chunk encoding is not implemented") + assert.Contains(t, got, `"active_slot":"PIN_TMPX_1"`) + assert.Contains(t, got, `"ignored_slots":["PIN_TMPX_2","PIN_TMPX_3"]`) + }) + + t.Run("nil logger is a no-op", func(t *testing.T) { + // Defensive: NewTMPXSealer reassigns nil logger to slog.Default + // before calling the helper, but pinning the contract avoids a + // nil-deref if a caller ever reaches this directly. + assert.NotPanics(t, func() { + warnIfMultiSlotIgnored(nil, []string{"A", "B"}) + }) + }) +} + func TestNewTMPXSealerDisabled(t *testing.T) { sealer, err := NewTMPXSealer(t.Context(), TMPXConfig{}, nil, nil, nil) require.NoError(t, err) diff --git a/tmproto/doc.go b/tmproto/doc.go index b2eb625..976c0e0 100644 --- a/tmproto/doc.go +++ b/tmproto/doc.go @@ -1,3 +1,3 @@ -//go:generate sh -c "cd ../internal/generate && go run . -schema ../../adcp/schemas/tmp -enums ../../adcp/schemas/enums -merge-schemas ../../adcp/schemas/core -overlay go-overlays.json -out ../../tmproto/types_gen.go -pkg tmproto" +//go:generate sh -c "cd ../internal/generate && go run . -schema ../../adcp/schemas/trusted-match -enums ../../adcp/schemas/enums -merge-schemas ../../adcp/schemas/core -overlay go-overlays.json -out ../../tmproto/types_gen.go -pkg tmproto" package tmproto diff --git a/tmproto/tmpx_providers.go b/tmproto/tmpx_providers.go new file mode 100644 index 0000000..7de9566 --- /dev/null +++ b/tmproto/tmpx_providers.go @@ -0,0 +1,10 @@ +package tmproto + +// TmpxProviderEntry is the per-provider value type in +// IdentityMatchResponse.TmpxProviders. The schema defines this inline as +// `{ macros: [TmpxMacro] }`; expressed as a Go type here because the schema +// generator does not synthesize structs for inline object schemas. See +// docs/trusted-match/specification.mdx §IdentityMatchResponse. +type TmpxProviderEntry struct { + Macros []TmpxMacro `json:"macros"` +} diff --git a/tmproto/types_gen.go b/tmproto/types_gen.go index 6852c23..d6b3319 100644 --- a/tmproto/types_gen.go +++ b/tmproto/types_gen.go @@ -164,15 +164,23 @@ type IdentityMatchRequest struct { SealedCredentials []SealedCredential `json:"sealed_credentials,omitempty"` // Optional HPKE-sealed credentials addressed to specific audiences — the network-as-RP ("issuer-as-RP"/Mechanism B) carrier. Each payload is opaque to the publisher, who relays it untouched; the inner plaintext is an `attestation` (see identities[].attestation) scoped to the audience's relying party. Reuses the TMPX envelope format. Router handling (normative — see docs/trusted-match/specification.mdx): the router forwards each entry only to the provider that owns its `audience_kid` (not broadcast), folds `sealed_credentials` into the per-provider re-signature canonical bytes so an injected/swapped blob breaks the signature, and includes a `sealed_credentials_hash` in the dedup cache key. Receivers decrypt only entries whose `audience_kid` they hold a key for and ignore the rest. Receivers MUST bound count and size to prevent DoS amplification. } +// A single ad-server macro slot and the URL-safe value the publisher substitutes into it. +type TmpxMacro struct { + Name string `json:"name"` // Macro name as configured in the publisher's ad server (e.g. `PIN_TMPX_1`). MUST appear in the emitting provider's registered `tmpx_macros` list. Provider-namespaced so the publisher can target distinct slots per provider. + Value string `json:"value"` // Opaque, URL-safe wire string the publisher substitutes verbatim into the named macro slot. Publishers MUST NOT parse, decode, or transform this value. The protocol fixes the wire format so platforms interoperate; a platform that can carry raw bytes MAY optimize privately but the wire contract remains the URL-safe string. +} + // Response indicating which packages the user is eligible for. The serve_window_sec field defines a per-package single-shot fcap: after serving the user one impression on each eligible package, the publisher MUST re-query Identity Match before serving from those packages again. Extension fields (ext, context) are intentionally omitted to prevent data leakage across the identity privacy boundary. type IdentityMatchResponse struct { - AdcpVersion string `json:"adcp_version,omitempty"` // Release-precision AdCP version (VERSION.RELEASE, e.g. "3.0", "3.1", "3.1-beta"). On a request: the buyer's release pin — the seller validates against its supported_versions and returns VERSION_UNSUPPORTED on cross-major mismatch, or downshifts to the highest supported release within the same major. On a response: the release the seller actually served — clients SHOULD validate the response against that release's schema, not against their pin. Patches are not negotiated; surface them as build_version on capabilities for operational visibility. When omitted, falls back to adcp_major_version (deprecated) or server default. Buyers SHOULD emit both adcp_version and adcp_major_version through 3.x to remain compatible with sellers that only read the legacy field. NORMALIZATION: SDKs that read full-semver values from bundle metadata (e.g. ComplianceIndex.published_version = "3.1.0-beta.1") MUST normalize to release-precision ("3.1-beta.1") before emitting on the wire — meta-field values are NOT valid wire values. - AdcpMajorVersion int `json:"adcp_major_version,omitempty"` // DEPRECATED in favor of adcp_version (release-precision string). Servers MUST continue to honor this field through 3.x. Removed in 4.0. Original semantics: the AdCP major version the buyer's payloads conform to. Sellers validate against their supported major_versions and return VERSION_UNSUPPORTED if unsupported. When omitted, the seller assumes its highest supported version. - Type string `json:"type"` // Message type discriminator for deserialization. - RequestID string `json:"request_id"` // Echoed request identifier from the identity match request - EligiblePackageIDs []string `json:"eligible_package_ids"` // Package IDs the user is eligible for. Packages not listed are ineligible. - ServeWindowSec int `json:"serve_window_sec"` // Per-package single-shot fcap window, in seconds. After serving the user one impression on each eligible package within this window, the publisher MUST re-query Identity Match before serving from those packages again. This is NOT a router response cache TTL — it is a buyer-asserted serve throttle. Multi-impression frequency caps are handled separately by the buyer's impression tracker, which writes cap-fire events to the IdentityMatch cap-state store at the boundary regardless of this window. Maximum 300 — longer windows reduce IdentityMatch load but coarsen fcap granularity below what most campaigns require. - Tmpx string `json:"tmpx,omitempty"` // HPKE-encrypted exposure token containing the resolved user identity tokens. The publisher substitutes this into creative tracking URLs as {TMPX}. The buyer's impression pixel receives the token at serve time, enabling real-time per-user frequency state updates. Wire format: kid.base64url_nopad(ciphertext) — unpadded base64url per RFC 4648 section 5 (no = characters). Publishers MUST treat this value as opaque pass-through data. + AdcpVersion string `json:"adcp_version,omitempty"` // Release-precision AdCP version (VERSION.RELEASE, e.g. "3.0", "3.1", "3.1-beta"). On a request: the buyer's release pin — the seller validates against its supported_versions and returns VERSION_UNSUPPORTED on cross-major mismatch, or downshifts to the highest supported release within the same major. On a response: the release the seller actually served — clients SHOULD validate the response against that release's schema, not against their pin. Patches are not negotiated; surface them as build_version on capabilities for operational visibility. When omitted, falls back to adcp_major_version (deprecated) or server default. Buyers SHOULD emit both adcp_version and adcp_major_version through 3.x to remain compatible with sellers that only read the legacy field. NORMALIZATION: SDKs that read full-semver values from bundle metadata (e.g. ComplianceIndex.published_version = "3.1.0-beta.1") MUST normalize to release-precision ("3.1-beta.1") before emitting on the wire — meta-field values are NOT valid wire values. + AdcpMajorVersion int `json:"adcp_major_version,omitempty"` // DEPRECATED in favor of adcp_version (release-precision string). Servers MUST continue to honor this field through 3.x. Removed in 4.0. Original semantics: the AdCP major version the buyer's payloads conform to. Sellers validate against their supported major_versions and return VERSION_UNSUPPORTED if unsupported. When omitted, the seller assumes its highest supported version. + Type string `json:"type"` // Message type discriminator for deserialization. + RequestID string `json:"request_id"` // Echoed request identifier from the identity match request + EligiblePackageIDs []string `json:"eligible_package_ids"` // Package IDs the user is eligible for. Packages not listed are ineligible. + ServeWindowSec int `json:"serve_window_sec"` // Per-package single-shot fcap window, in seconds. After serving the user one impression on each eligible package within this window, the publisher MUST re-query Identity Match before serving from those packages again. This is NOT a router response cache TTL — it is a buyer-asserted serve throttle. Multi-impression frequency caps are handled separately by the buyer's impression tracker, which writes cap-fire events to the IdentityMatch cap-state store at the boundary regardless of this window. Maximum 300 — longer windows reduce IdentityMatch load but coarsen fcap granularity below what most campaigns require. + Tmpx string `json:"tmpx,omitempty"` // DEPRECATED in favor of tmpx_providers. Routers MAY continue to populate this field for back-compat with consumers that only know the single-token shape; when both fields are present, tmpx_providers is authoritative. Single HPKE-encrypted exposure token containing the resolved user identity tokens. Wire format: kid.base64url_nopad(ciphertext) — unpadded base64url per RFC 4648 section 5 (no = characters). Publishers MUST treat this value as opaque pass-through data. Removed in 4.0. + TmpxMacros []TmpxMacro `json:"tmpx_macros,omitempty"` // Provider-emitted: the identity agent's ordered TMPX chunks paired with the macro name each chunk fills. Macro names MUST be drawn from the provider's registered `tmpx_macros` list (provider-registration.json) and appear in the same order — names are part of operational setup, not protocol-synthesized. Capped at 2 chunks in v1; the cap MAY rise without a shape change. Each `value` is an opaque URL-safe wire string the publisher substitutes verbatim into the matching ad-server macro slot — publishers MUST NOT parse, decode, transform, or choose an encoding. The router collects entries from every provider that emits them into `tmpx_providers`, keyed by provider_id; consumers reading the merged response SHOULD consume `tmpx_providers` and ignore `tmpx_macros` at the root. + TmpxProviders map[string]TmpxProviderEntry `json:"tmpx_providers,omitempty"` // Router-populated: TMPX macro/value pairs grouped by the originating identity provider's provider_id, so the publisher fires each provider's tokens through that provider's specific ad-server macros (configured per provider in GAM / VAST URL / DOOH play log). Each entry's `macros[]` is a copy of the provider's emitted `tmpx_macros` ordered list. SHAPE CHANGE: was `Map` in the experimental surface that shipped in #5689; now `Map` to carry exact macro/value pairs and support multi-chunk TMPX. Sanctioned by the experimental contract (`x-status: experimental` on this schema). Required by router conformance when any identity provider emitted TMPX in this request; collapsing per-provider tokens into a single string loses attribution and breaks per-provider impression accounting. Map keys MUST be valid provider_ids registered for this fan-out. Publishers MUST traffic only the macro names the response advertises; macro names MUST NOT be derived from provider_id at runtime. } // Lightweight price for a TMP offer. Used when the product supports variable pricing and the buyer specifies a price at match time. @@ -195,7 +203,7 @@ type Offer struct { // Declares a TMP provider's endpoint, capabilities, and operational parameters. Used in router configuration (static YAML or dynamic API) and referenced by product-level provider entries. The publisher controls which providers participate in their ad decisioning. Endpoint URLs MUST be validated against SSRF, and dynamic registration endpoints MUST authenticate callers — see docs/trusted-match/specification#provider-registration-security. type ProviderRegistration struct { - ProviderID string `json:"provider_id"` // Stable identifier for this provider registration. Used in logs, metrics, and cache keys. Publishers assign this — it is not the provider's agent_url. + ProviderID string `json:"provider_id"` // Stable identifier for this provider registration. Used in logs, metrics, cache keys, and as the key in `tmpx_providers` on the identity-match response so the publisher can route each provider's TMPX `macros[]` to that provider's pre-configured ad-server slots (names registered in `tmpx_macros` below — macro names MUST NOT be derived from `provider_id` at runtime). Publishers assign this — it is not the provider's agent_url. Charset is constrained to a safe alphanumeric/underscore set so the value can appear in operational surfaces (logs, metrics, dashboards) without quoting. Endpoint string `json:"endpoint"` // Base URL the router calls. The router appends /context for Context Match and /identity for Identity Match. MUST be HTTPS in production, validated against the canonical reserved IPv4 and IPv6 ranges, with the TCP connection pinned to the validated IP (DNS re-resolution alone is insufficient against rebinding). Publishers comparing two provider registrations for the same `endpoint` MUST canonicalize both per the AdCP URL canonicalization rules; two registrations differing only in case, default port, or path-slash collapsing are the same provider. See docs/trusted-match/specification#provider-registration-security, docs/building/implementation/security#webhook-url-validation-ssrf, and docs/reference/url-canonicalization. ContextMatch bool `json:"context_match,omitempty"` // Provider handles Context Match requests (POST /context). IdentityMatch bool `json:"identity_match,omitempty"` // Provider handles Identity Match requests (POST /identity). @@ -204,5 +212,6 @@ type ProviderRegistration struct { Properties []string `json:"properties,omitempty"` // Property RIDs (UUID v7) this provider serves. When present, the router only sends requests from these properties to this provider. When absent, the provider serves all properties. TimeoutMs int `json:"timeout_ms,omitempty"` // Per-provider timeout in milliseconds. The router skips this provider if it does not respond within this budget. Must be less than or equal to the router's overall latency_budget_ms. The router may further reduce this based on adaptive timeout allocation. Priority int `json:"priority,omitempty"` // Provider ordering for merge conflict resolution. Lower values have higher priority. When two providers return offers for the same package_id (a configuration error), the router keeps the offer from the higher-priority provider. Also used for adaptive timeout allocation — higher-priority providers receive a larger share of the latency budget. + TmpxMacros []string `json:"tmpx_macros,omitempty"` // Stable, provider-namespaced ad-server macro names this provider's TMPX response fills, ordered. Publishers traffic these exact names in their ad server (GAM key-values, VAST URL macros, DOOH play-log fields); the router places each provider's TMPX chunks into the matching slots on the identity-match response (`tmpx_providers[provider_id].macros[]`). Names MUST be provider-namespaced so publishers can configure distinct macros per provider (e.g. `PIN_TMPX_1`, `PIN_TMPX_2` for one provider; `NOVA_TMPX_1` for another). Ordered so multi-chunk TMPX values (when the opaque token exceeds one macro slot) are placed deterministically. Capped at 2 entries in v1; the cap MAY be raised in a later version without a shape change. A provider that emits TMPX on its identity-match response (populates `tmpx_macros[]` there) MUST also register this list — otherwise the router has no slot names to forward — but the schema cannot enforce this because "emits TMPX" is not a schema-visible predicate. Providers that do not emit TMPX omit this field. Status ProviderStatus `json:"status,omitempty"` // Provider lifecycle status. Active providers receive requests. Inactive providers are skipped entirely. Draining providers stop receiving new requests but in-flight requests complete normally. }