diff --git a/.changeset/pre-3-1-field-projection.md b/.changeset/pre-3-1-field-projection.md new file mode 100644 index 000000000..e0e74baec --- /dev/null +++ b/.changeset/pre-3-1-field-projection.md @@ -0,0 +1,15 @@ +--- +'@adcp/sdk': minor +--- + +feat: strip AdCP 3.1-only request fields when the negotiated target is pre-3.1 + +`BrandReference` is a closed object (`additionalProperties: false`) in every AdCP version. The 3.1 inline override `brand_kit_override` was added in AdCP 3.1 and does not exist in the 3.0 schema — 3.0 sellers reject requests carrying it. `industries` and `data_subject_contestation` are declared in AdCP 3.0 GA and are accepted by 3.0 sellers; they are left on the wire. Separately, the `get_products` discovery webhook (`push_notification_config`, a 3.1 feature) caused the SDK to throw for pre-3.1 clients. + +The client now omits 3.1-only fields when the negotiated target is pre-3.1 (the client is pinned below 3.1, or the seller does not advertise 3.1 via `get_adcp_capabilities`), degrading gracefully: + +- `brand_kit_override` is stripped from outbound brand references on `create_media_buy`, `sync_accounts`, and `get_products`; identity fields (`domain`, `brand_id`) and 3.0 fields (`industries`, `data_subject_contestation`) are preserved. +- The auto-injected `get_products` discovery webhook is skipped (results are polled via `tasks/get`) instead of throwing. An explicit caller-supplied `push_notification_config` on a pre-3.1 client still throws (unchanged). +- Both are surfaced as `debug_logs` drift entries (`pre31_brand_fields_stripped`, `pre31_webhook_degraded`) so the drops are visible and not silent. + +The brand strip is keyed on `shouldOmit31Fields(clientVersion, sellerCapabilities)` — correct for 3.0-pinned callers today and per-seller when a caller pins to 3.1. The webhook suppression is keyed on the client pin only (`isPre31AdcpVersion`), since suppression runs before `detectServerVersion` populates seller caps. diff --git a/.changeset/ws-security-patch.md b/.changeset/ws-security-patch.md new file mode 100644 index 000000000..14fe28174 --- /dev/null +++ b/.changeset/ws-security-patch.md @@ -0,0 +1,5 @@ +--- +"@adcp/sdk": patch +--- + +Bump ws to 8.21.0 to resolve high-severity memory exhaustion DoS vulnerability (GHSA-96hv-2xvq-fx4p). diff --git a/package-lock.json b/package-lock.json index ec36194ac..ebe0351b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,7 @@ "structured-headers": "^2.0.2", "tldts": "^7.0.29", "undici": "^6.25.0", - "ws": "^8.20.0", + "ws": "^8.21.0", "yaml": "^2.7.1" }, "bin": { @@ -1727,6 +1727,7 @@ "integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cluster-key-slot": "1.1.2", "generic-pool": "3.9.0", @@ -2012,6 +2013,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz", "integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -2147,6 +2149,7 @@ "integrity": "sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.59.4", "@typescript-eslint/types": "8.59.4", @@ -2357,6 +2360,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3365,6 +3369,7 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.4.0.tgz", "integrity": "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==", "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", @@ -3668,6 +3673,7 @@ "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -4194,6 +4200,7 @@ "integrity": "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -5639,6 +5646,7 @@ "integrity": "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "pg-connection-string": "^2.13.0", "pg-pool": "^3.14.0", @@ -6832,6 +6840,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -7041,6 +7050,7 @@ "integrity": "sha512-wKh+lhdmMFivMlc6vRRcMGXeGEHGU2g8a2CkPTJjJlwRf1iXbimWIPcFolCqe4E0d/FRtGszpIrsp3WLpDB8Pw==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@gerrit0/mini-shiki": "^3.23.0", "lunr": "^2.3.9", @@ -7077,6 +7087,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7407,6 +7418,7 @@ "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index a66982937..7e16eb829 100644 --- a/package.json +++ b/package.json @@ -400,7 +400,7 @@ "structured-headers": "^2.0.2", "tldts": "^7.0.29", "undici": "^6.25.0", - "ws": "^8.20.0", + "ws": "^8.21.0", "yaml": "^2.7.1" }, "peerDependencies": { diff --git a/src/lib/adapters/version/3.0/brand-fields.ts b/src/lib/adapters/version/3.0/brand-fields.ts new file mode 100644 index 000000000..9a74bfaf3 --- /dev/null +++ b/src/lib/adapters/version/3.0/brand-fields.ts @@ -0,0 +1,29 @@ +import { omit31BrandFields } from '../../../utils/adcp-version-config'; +import type { VersionAdapter, VersionDrift } from '../types'; + +const BRAND_DRIFT: VersionDrift = { + type: 'pre31_brand_fields_stripped', + message: + 'brand_kit_override stripped: field requires AdCP 3.1 but the target seller does not advertise 3.1 support. ' + + 'These fields will not reach the seller; brand identity (domain, brand_id) is preserved.', + strippedFields: ['brand_kit_override'], +}; + +function stripTopLevelBrand(params: unknown): { params: unknown; drift?: VersionDrift } { + if (!params || typeof params !== 'object') return { params }; + const req = params as Record; + if (!req.brand) return { params }; + const stripped = omit31BrandFields(req.brand); + if (stripped === req.brand) return { params }; + return { params: { ...req, brand: stripped }, drift: BRAND_DRIFT }; +} + +export const createMediaBuyAdapter: VersionAdapter = { + toolName: 'create_media_buy', + adaptRequest: stripTopLevelBrand, +}; + +export const getProductsAdapter: VersionAdapter = { + toolName: 'get_products', + adaptRequest: stripTopLevelBrand, +}; diff --git a/src/lib/adapters/version/3.0/index.ts b/src/lib/adapters/version/3.0/index.ts new file mode 100644 index 000000000..ba90565a6 --- /dev/null +++ b/src/lib/adapters/version/3.0/index.ts @@ -0,0 +1,9 @@ +import { createMediaBuyAdapter, getProductsAdapter } from './brand-fields'; +import { syncAccountsAdapter } from './sync-accounts'; +import type { VersionAdapter } from '../types'; + +export const v30Adapters: ReadonlyArray = [ + createMediaBuyAdapter, + getProductsAdapter, + syncAccountsAdapter, +]; diff --git a/src/lib/adapters/version/3.0/sync-accounts.ts b/src/lib/adapters/version/3.0/sync-accounts.ts new file mode 100644 index 000000000..258461899 --- /dev/null +++ b/src/lib/adapters/version/3.0/sync-accounts.ts @@ -0,0 +1,29 @@ +import { omit31BrandFields } from '../../../utils/adcp-version-config'; +import type { VersionAdapter, VersionDrift } from '../types'; + +const BRAND_DRIFT: VersionDrift = { + type: 'pre31_brand_fields_stripped', + message: + 'brand_kit_override stripped from accounts[].brand: field requires AdCP 3.1 but the target seller does not advertise 3.1 support. ' + + 'These fields will not reach the seller; brand identity (domain, brand_id) is preserved.', + strippedFields: ['brand_kit_override'], +}; + +export const syncAccountsAdapter: VersionAdapter = { + toolName: 'sync_accounts', + adaptRequest(params) { + if (!params || typeof params !== 'object') return { params }; + const req = params as Record; + if (!Array.isArray(req.accounts)) return { params }; + let stripped = false; + const accounts = (req.accounts as Array>).map(a => { + if (!a || typeof a !== 'object' || !a.brand) return a; + const strippedBrand = omit31BrandFields(a.brand); + if (strippedBrand === a.brand) return a; + stripped = true; + return { ...a, brand: strippedBrand }; + }); + if (!stripped) return { params }; + return { params: { ...req, accounts }, drift: BRAND_DRIFT }; + }, +}; diff --git a/src/lib/adapters/version/index.ts b/src/lib/adapters/version/index.ts new file mode 100644 index 000000000..05fe6829c --- /dev/null +++ b/src/lib/adapters/version/index.ts @@ -0,0 +1,59 @@ +/** + * Registry of per-version request adapters, keyed by target AdCP version string. + * + * `resolveAdapterKey` maps a (clientVersion, sellerCaps) pair to the adapter + * key whose adapters should be applied. `getVersionAdapter` looks up the + * per-tool adapter for that key. The dispatch in `SingleAgentClient` calls + * both; tools without a registered adapter for the resolved key pass through + * unchanged. + * + * Add a new version transition by: + * 1. Create a `version//` directory with per-tool adapter modules. + * 2. Collect them in `version//index.ts` as a `ReadonlyArray`. + * 3. Register below. + * 4. Add tests to the conformance suite. + */ + +import { shouldOmit31Fields } from '../../utils/adcp-version-config'; +import type { VersionAdapter } from './types'; +import { v30Adapters } from './3.0/index'; + +export type { VersionAdapter, VersionDrift } from './types'; + +const REGISTRY = new Map>(); + +function register(version: string, adapters: ReadonlyArray): void { + REGISTRY.set(version, new Map(adapters.map(a => [a.toolName, a]))); +} + +register('3.0', v30Adapters); + +/** + * Look up the version adapter for a given (adapterKey, toolName) pair. + * Returns `undefined` when no adapter is registered — caller passes through. + */ +export function getVersionAdapter(adapterKey: string, toolName: string): VersionAdapter | undefined { + return REGISTRY.get(adapterKey)?.get(toolName); +} + +/** + * Resolve the adapter key for the current (clientVersion, sellerCaps) pair. + * Returns `undefined` when no adaptation is needed (both sides speak the same + * or a compatible version). Returns `'3.0'` when either the client is pinned + * below 3.1 or the seller does not advertise 3.1 support. + */ +export function resolveAdapterKey( + clientVersion: string | undefined, + caps: { supportedVersions?: string[]; buildVersion?: string } | undefined +): string | undefined { + if (shouldOmit31Fields(clientVersion, caps)) return '3.0'; + return undefined; +} + +/** + * Names of tools registered for a given adapter key. + * Used by the conformance test suite as the authoritative list. + */ +export function listVersionAdapterTools(adapterKey: string): string[] { + return [...(REGISTRY.get(adapterKey)?.keys() ?? [])]; +} diff --git a/src/lib/adapters/version/types.ts b/src/lib/adapters/version/types.ts new file mode 100644 index 000000000..d34b55739 --- /dev/null +++ b/src/lib/adapters/version/types.ts @@ -0,0 +1,33 @@ +/** + * Registry pattern for AdCP protocol-version request adapters. + * + * Each AdCP tool that needs adaptation when targeting a seller on an older + * protocol version registers a `VersionAdapter`. The adapter's `adaptRequest` + * strips or rewrites fields the target version does not accept, returning the + * adapted params and an optional `VersionDrift` describing what changed. + * + * Adapters live in per-version directories (`version//`) and are + * collected by the registry in `version/index.ts`. Adding support for a new + * version transition means adding a sibling directory — nothing else changes. + */ + +export interface VersionDrift { + /** Machine-readable event type surfaced in `debug_logs`. */ + type: string; + /** Human-readable description of what was stripped and why. */ + message: string; + /** Names of the fields removed from the request. */ + strippedFields?: string[]; +} + +export interface VersionAdapter { + /** AdCP tool name this adapter handles (snake_case, e.g. `create_media_buy`). */ + readonly toolName: string; + + /** + * Adapt a request for the target protocol version. Returns the (possibly + * modified) params and an optional drift description. When no adaptation is + * needed the original params reference is returned unchanged. + */ + adaptRequest(params: unknown): { params: unknown; drift?: VersionDrift }; +} diff --git a/src/lib/core/SingleAgentClient.ts b/src/lib/core/SingleAgentClient.ts index 2d9dc6a9e..0c7314e47 100644 --- a/src/lib/core/SingleAgentClient.ts +++ b/src/lib/core/SingleAgentClient.ts @@ -6,6 +6,7 @@ import type { AgentConfig } from '../types'; import { ADCP_ENVELOPE_FIELDS } from '../types/adcp'; import { parseAdcpMajorVersion, type AdcpVersion } from '../version'; import { isAdcpVersionSupported, isPre31AdcpVersion, resolveAdcpVersion } from '../utils/adcp-version-config'; +import { getVersionAdapter, resolveAdapterKey } from '../adapters/version'; import type { GetProductsRequest, GetProductsResponse, @@ -1583,6 +1584,14 @@ export class SingleAgentClient { }); this.assertRequestSupportedByConfiguredVersion(taskType, normalizedParams, options); + // Degrade an auto-injected discovery webhook to polling for pre-3.1 pins + // (get_products / get_signals). `effectiveOptions` carries disableWebhook + // so no push_notification_config reaches a seller that can't accept it. + const { options: effectiveOptions, driftLog: webhookDriftLog } = this.suppressPre31DiscoveryWebhook( + taskType, + options + ); + // Inject an idempotency_key for mutating tools before schema validation // so callers don't have to supply one. TaskExecutor also guards against // missing keys, but validation happens here first — do the injection up @@ -1635,9 +1644,13 @@ export class SingleAgentClient { this.executor.validateRequest(taskType, normalizedParams); } - // Adapt request for v2 servers if needed + // Adapt request for the detected server and AdCP protocol versions. const serverVersion = await this.detectServerVersion(); - const adaptedParams = await this.adaptRequestForServerVersion(taskType, normalizedParams); + const { params: adaptedParams, driftLogs: adaptDriftLogs } = this.adaptRequest( + taskType, + normalizedParams, + serverVersion + ); // Symmetric to the pre-adapter v3 pass above: when the adapter // rewrote the request for a v2 server, warn-validate the adapted @@ -1645,7 +1658,8 @@ export class SingleAgentClient { // here and merged into result.metadata.debug_logs after executeTask // returns — without that merge the warning would silently drop on // the floor and adapter drift would land in production unnoticed. - const v25DriftLogs: any[] = []; + const v25DriftLogs: any[] = [...adaptDriftLogs]; + if (webhookDriftLog) v25DriftLogs.push(webhookDriftLog); if (serverVersion === 'v2') { this.executor.validateAdaptedRequestAgainstV2(taskType, adaptedParams, v25DriftLogs); } @@ -1655,15 +1669,16 @@ export class SingleAgentClient { taskType, adaptedParams, inputHandler, - options, + effectiveOptions, serverVersion ); // Merge collected drift into the executor's debug_logs so adopters - // reading result.debug_logs see post-adapter v2.5 warnings alongside - // the executor's own logs. On error paths the executor may not surface - // result.debug_logs at all — drift collected before the failure is - // dropped, matching the executor's own debug-log behavior. + // reading result.debug_logs see post-adapter v2.5 warnings (and any + // pre-3.1 webhook-degradation notice) alongside the executor's own logs. + // On error paths the executor may not surface result.debug_logs at all; + // drift collected before the failure is dropped, matching the executor's + // own debug-log behavior. if (v25DriftLogs.length > 0) { result.debug_logs = [...(result.debug_logs ?? []), ...v25DriftLogs]; } @@ -2047,17 +2062,24 @@ export class SingleAgentClient { } /** - * Adapt request parameters for the detected server version - * - * Converts v3-style requests to v2 format when talking to v2 servers. + * Adapt a request for the detected server wire version and the seller's + * AdCP protocol version. Applies wire-format adapters (v2.5) when talking + * to a v2 server, then applies protocol-version adapters (e.g. stripping + * 3.1-only fields for a 3.0 seller). Returns the adapted params and any + * drift log entries describing what was changed. + * + * Runs after `detectServerVersion` so `cachedCapabilities` is populated + * and the protocol-version adapters see the seller's declared caps. */ - private async adaptRequestForServerVersion(taskType: string, params: any): Promise { - // Get server version (cached after first call) - const version = await this.detectServerVersion(); - + private adaptRequest( + taskType: string, + params: any, + serverVersion: string + ): { params: any; driftLogs: Record[] } { + const driftLogs: Record[] = []; let adapted = params; - if (version !== 'v3') { + if (serverVersion !== 'v3') { // Dispatch through the legacy v2.5 adapter registry. Per-tool pairs // live in `src/lib/adapters/legacy/v2-5/.ts`. Tools without a // registered pair (or pairs whose request side is pass-through) @@ -2065,7 +2087,7 @@ export class SingleAgentClient { // adding a sibling `legacy//` directory, not editing // this dispatch. const pair = getV25Adapter(taskType); - if (pair) adapted = pair.adaptRequest(params); + if (pair) adapted = pair.adaptRequest(adapted); } // Strip any top-level fields not declared in the agent's tool schema. @@ -2090,57 +2112,81 @@ export class SingleAgentClient { // (e.g. `applyBrandInvariant` in the storyboard runner — see #940), // not to lean on this strip path as a backstop. const toolSchema = this.cachedToolSchemas?.get(taskType); - if (!toolSchema || Object.keys(toolSchema).length === 0) return adapted; + if (toolSchema && Object.keys(toolSchema).length > 0) { + const declaredFields = new Set(Object.keys(toolSchema)); + + // The v2 adapter may rename fields (e.g. brand → brand_manifest) that a + // v3 server — misdetected as v2 — doesn't declare. Reconcile known + // adapter mappings so the value isn't silently dropped. + // + // CRITICAL: only alias when the JS type of the moved value is + // compatible with the destination field's declared shape. v2.5 sellers + // (e.g. Wonderstruck) declare `brand` in their tool schema as a + // BrandReference object — v2 adapter produces a `brand_manifest` URL + // string, and blindly aliasing the string into the object slot causes + // the seller to reject with `Input should be a valid dictionary or + // instance of BrandReference`. Skip the alias when shapes don't match + // and let the field-stripping path drop the v2-shaped value cleanly. + const adapterAliases: [string, string][] = [['brand_manifest', 'brand']]; + for (const [adapterField, schemaField] of adapterAliases) { + if ( + adapted[adapterField] !== undefined && + !declaredFields.has(adapterField) && + declaredFields.has(schemaField) && + adapted[schemaField] === undefined && + valueMatchesSchemaType(adapted[adapterField], (toolSchema as Record)[schemaField]) + ) { + adapted[schemaField] = adapted[adapterField]; + delete adapted[adapterField]; + } + } - const declaredFields = new Set(Object.keys(toolSchema)); + // Protocol envelope fields are always preserved — they live at the + // protocol layer, not in individual tool schemas. + const envelopeFields = ADCP_ENVELOPE_FIELDS; + const filtered: Record = {}; + const schemaStripped: string[] = []; - // The v2 adapter may rename fields (e.g. brand → brand_manifest) that a - // v3 server — misdetected as v2 — doesn't declare. Reconcile known - // adapter mappings so the value isn't silently dropped. - // - // CRITICAL: only alias when the JS type of the moved value is - // compatible with the destination field's declared shape. v2.5 sellers - // (e.g. Wonderstruck) declare `brand` in their tool schema as a - // BrandReference object — v2 adapter produces a `brand_manifest` URL - // string, and blindly aliasing the string into the object slot causes - // the seller to reject with `Input should be a valid dictionary or - // instance of BrandReference`. Skip the alias when shapes don't match - // and let the field-stripping path drop the v2-shaped value cleanly. - const adapterAliases: [string, string][] = [['brand_manifest', 'brand']]; - for (const [adapterField, schemaField] of adapterAliases) { - if ( - adapted[adapterField] !== undefined && - !declaredFields.has(adapterField) && - declaredFields.has(schemaField) && - adapted[schemaField] === undefined && - valueMatchesSchemaType(adapted[adapterField], (toolSchema as Record)[schemaField]) - ) { - adapted[schemaField] = adapted[adapterField]; - delete adapted[adapterField]; + for (const [key, value] of Object.entries(adapted)) { + if (declaredFields.has(key) || envelopeFields.has(key)) { + filtered[key] = value; + } else { + schemaStripped.push(key); + } } - } - // Protocol envelope fields are always preserved — they live at the - // protocol layer, not in individual tool schemas. - const envelopeFields = ADCP_ENVELOPE_FIELDS; - const filtered: Record = {}; - const stripped: string[] = []; - - for (const [key, value] of Object.entries(adapted)) { - if (declaredFields.has(key) || envelopeFields.has(key)) { - filtered[key] = value; - } else { - stripped.push(key); + if (schemaStripped.length > 0) { + console.warn( + `[AdCP] Stripping fields not declared in agent "${this.agent.id}" schema for ${taskType}: ${schemaStripped.join(', ')}` + ); } + + adapted = filtered; } - if (stripped.length > 0) { - console.warn( - `[AdCP] Stripping fields not declared in agent "${this.agent.id}" schema for ${taskType}: ${stripped.join(', ')}` - ); + // Protocol version adaptation: strip fields not accepted by the target + // AdCP version. `resolveAdapterKey` returns the effective target version + // based on the client pin and the seller's advertised caps; adapters live + // in `src/lib/adapters/version//`. + const adapterKey = resolveAdapterKey(this.resolvedAdcpVersion, this.cachedCapabilities); + if (adapterKey) { + const versionAdapter = getVersionAdapter(adapterKey, taskType); + if (versionAdapter) { + const result = versionAdapter.adaptRequest(adapted); + adapted = result.params; + if (result.drift) { + driftLogs.push({ + ...result.drift, + taskName: taskType, + clientVersion: this.resolvedAdcpVersion, + targetVersion: adapterKey, + timestamp: new Date().toISOString(), + }); + } + } } - return filtered; + return { params: adapted, driftLogs }; } /** @@ -2973,6 +3019,15 @@ export class SingleAgentClient { skipAccountValidation: options?.skipAccountValidation, }); this.assertRequestSupportedByConfiguredVersion(taskName, normalizedParams, options); + + // Degrade an auto-injected discovery webhook to polling for pre-3.1 pins + // (get_products / get_signals). `effectiveOptions` carries disableWebhook + // so no push_notification_config reaches a seller that can't accept it. + const { options: effectiveOptions, driftLog: webhookDriftLog } = this.suppressPre31DiscoveryWebhook( + taskName, + options + ); + await this.validateTaskFeatures(taskName); if (this.config.requireV3ForMutations && isMutatingTask(taskName)) { await this.requireSupportedMajor(taskName); @@ -2988,15 +3043,19 @@ export class SingleAgentClient { this.executor.validateRequest(taskName, normalizedParams); } - // Adapt request for the server's protocol version (e.g. strip v3-only - // fields like buying_mode when talking to v2 agents). + // Adapt request for the detected server and AdCP protocol versions. const serverVersion = await this.detectServerVersion(); - const adaptedParams = await this.adaptRequestForServerVersion(taskName, normalizedParams); + const { params: adaptedParams, driftLogs: adaptDriftLogs } = this.adaptRequest( + taskName, + normalizedParams, + serverVersion + ); // Symmetric warn-only post-adapter pass against the v2.5 schema bundle. // Drift gets surfaced via result.metadata.debug_logs so adapter // regressions in production aren't silently swallowed. - const v25DriftLogs: any[] = []; + const v25DriftLogs: any[] = [...adaptDriftLogs]; + if (webhookDriftLog) v25DriftLogs.push(webhookDriftLog); if (serverVersion === 'v2') { this.executor.validateAdaptedRequestAgainstV2(taskName, adaptedParams, v25DriftLogs); } @@ -3006,7 +3065,7 @@ export class SingleAgentClient { taskName, adaptedParams, inputHandler, - options, + effectiveOptions, serverVersion ); @@ -3622,7 +3681,7 @@ export class SingleAgentClient { // Cache raw tool schemas for field-level compatibility checks (e.g. buying_mode on get_products). // INVARIANT: must be assigned before cachedCapabilities below so that any code path - // reaching adaptRequestForServerVersion always finds the schemas populated. + // reaching adaptRequest always finds the schemas populated. this.cachedToolSchemas = new Map( agentInfo.tools .filter(t => t.inputSchema?.properties) @@ -3942,12 +4001,10 @@ export class SingleAgentClient { * result set. A pre-3.1 client pin should not silently drop those controls * or let a generic schema error hide the recovery path. */ - private assertRequestSupportedByConfiguredVersion(taskName: string, params: unknown, options?: TaskOptions): void { + private assertRequestSupportedByConfiguredVersion(taskName: string, params: unknown, _options?: TaskOptions): void { if (!isPre31AdcpVersion(this.resolvedAdcpVersion)) return; const request = params && typeof params === 'object' && !Array.isArray(params) ? (params as Record) : {}; - const willInjectDiscoveryWebhook = - !options?.disableWebhook && selectWebhookTemplate(this.config.webhookUrlTemplate, taskName) !== undefined; if (taskName === 'get_signals' && request.discovery_mode === 'wholesale') { this.throwPre31UnsupportedFeature(taskName, 'discovery_mode', 'get_signals.discovery_mode=wholesale', { @@ -3956,10 +4013,12 @@ export class SingleAgentClient { }); } - if ( - (taskName === 'get_products' || taskName === 'get_signals') && - (request.push_notification_config !== undefined || willInjectDiscoveryWebhook) - ) { + // An EXPLICIT push_notification_config on a discovery task is caller misuse + // while hard-pinned <3.1: surface it rather than silently dropping the + // caller's webhook. An AUTO-injected discovery webhook (from + // `webhookUrlTemplate`) is degraded to polling instead; see + // `suppressPre31DiscoveryWebhook`. + if ((taskName === 'get_products' || taskName === 'get_signals') && request.push_notification_config !== undefined) { this.throwPre31UnsupportedFeature(taskName, 'push_notification_config', `${taskName}.push_notification_config`, { capabilityPath: 'adcp.supported_versions', suffix: 'Probe get_adcp_capabilities at adcp.supported_versions before relying on discovery task webhooks.', @@ -3972,6 +4031,45 @@ export class SingleAgentClient { // payload. } + /** + * Degrade the auto-injected get_products / get_signals discovery webhook to + * polling when the client is pinned below 3.1. Discovery-task + * `push_notification_config` is an AdCP 3.1 feature; a pre-3.1 seller would + * reject it. Rather than throwing on the library's own auto-injected webhook + * (which the caller never asked for), suppress it via `disableWebhook` and + * record a `pre31_webhook_degraded` drift entry so the loss of push is + * visible in `debug_logs`. + * + * Returns the effective options (a `disableWebhook` copy when suppressing, + * otherwise the caller's unchanged) and an optional drift log to merge into + * the result. Explicit caller-supplied `push_notification_config` is handled + * by `assertRequestSupportedByConfiguredVersion` (it throws) and never + * reaches here. + */ + private suppressPre31DiscoveryWebhook( + taskName: string, + options?: TaskOptions + ): { options: TaskOptions | undefined; driftLog?: Record } { + if (!isPre31AdcpVersion(this.resolvedAdcpVersion)) return { options }; + if (taskName !== 'get_products' && taskName !== 'get_signals') return { options }; + if (options?.disableWebhook) return { options }; + if (selectWebhookTemplate(this.config.webhookUrlTemplate, taskName) === undefined) return { options }; + + return { + options: { ...options, disableWebhook: true }, + driftLog: { + type: 'pre31_webhook_degraded', + message: + `${taskName} discovery webhook degraded to polling: discovery-task push_notification_config ` + + `requires AdCP 3.1, but this client is pinned to ${this.resolvedAdcpVersion}. ` + + 'The seller will not receive a push webhook; poll for the result instead.', + timestamp: new Date().toISOString(), + taskName, + clientVersion: this.resolvedAdcpVersion, + }, + }; + } + private throwPre31UnsupportedFeature( taskName: string, field: string, @@ -4142,7 +4240,7 @@ export class SingleAgentClient { * but unknown top-level keys pass through. This matters because callers — * including the storyboard runner's `applyBrandInvariant` — inject * scoping fields (`brand`, `account`) onto every outgoing request, and - * `adaptRequestForServerVersion` strips those fields downstream for tools + * `adaptRequest` strips those fields downstream for tools * whose schema doesn't declare them. A strict parse here rejects the * injected fields before the adapter gets a chance to clean them up, so * the two passes have to agree on "extra keys are fine." diff --git a/src/lib/utils/adcp-version-config.ts b/src/lib/utils/adcp-version-config.ts index f804fa38a..65a0391a6 100644 --- a/src/lib/utils/adcp-version-config.ts +++ b/src/lib/utils/adcp-version-config.ts @@ -118,6 +118,41 @@ export function isPre31AdcpVersion(version: string | undefined): boolean { return Number.isFinite(minor) && minor < 1; } +/** Does the seller advertise AdCP 3.1+ support (via get_adcp_capabilities)? */ +export function sellerAdvertises31(caps: { supportedVersions?: string[]; buildVersion?: string } | undefined): boolean { + if (!caps) return false; + if (caps.buildVersion && !isPre31AdcpVersion(caps.buildVersion)) return true; + return (caps.supportedVersions ?? []).some(v => !isPre31AdcpVersion(v)); +} + +/** + * Whether to omit AdCP 3.1-only request fields from the wire. Omit when the + * client is pinned below 3.1, or when the seller does not advertise 3.1 + * support (legacy 3.0 sellers, and sellers whose capabilities were synthesized + * from a tool list rather than declared). + */ +export function shouldOmit31Fields( + resolvedClientVersion: string | undefined, + caps: { supportedVersions?: string[]; buildVersion?: string } | undefined +): boolean { + if (isPre31AdcpVersion(resolvedClientVersion)) return true; + return !sellerAdvertises31(caps); +} + +/** + * Strip the AdCP 3.1-only `brand_kit_override` field from a BrandReference. + * `industries` and `data_subject_contestation` are declared in AdCP 3.0 and + * must be left on the wire for 3.0 sellers that accept and route on them. + * Returns the original reference unchanged when `brand_kit_override` is absent. + */ +export function omit31BrandFields(brand: T): T { + if (!brand || typeof brand !== 'object' || Array.isArray(brand)) return brand; + const b = brand as Record; + if (!('brand_kit_override' in b)) return brand; + const { brand_kit_override, ...rest } = b; + return rest as T; +} + function prereleaseFamilyAlias(version: string): string | undefined { const match = /^(\d+\.\d+-[0-9A-Za-z-]+)\.\d+$/.exec(version); return match?.[1]; diff --git a/test/lib/adcp-version-projection.test.js b/test/lib/adcp-version-projection.test.js new file mode 100644 index 000000000..53892bec4 --- /dev/null +++ b/test/lib/adcp-version-projection.test.js @@ -0,0 +1,49 @@ +const { test } = require('node:test'); +const assert = require('node:assert'); +const { + sellerAdvertises31, + shouldOmit31Fields, + omit31BrandFields, +} = require('../../dist/lib/utils/adcp-version-config.js'); + +test('sellerAdvertises31: true when buildVersion >= 3.1', () => { + assert.equal(sellerAdvertises31({ buildVersion: '3.1.0' }), true); + assert.equal(sellerAdvertises31({ buildVersion: '3.2.1' }), true); +}); +test('sellerAdvertises31: true when supportedVersions contains a >=3.1 release', () => { + assert.equal(sellerAdvertises31({ supportedVersions: ['3.0', '3.1'] }), true); +}); +test('sellerAdvertises31: false for legacy 3.0-only sellers / missing fields', () => { + assert.equal(sellerAdvertises31(undefined), false); + assert.equal(sellerAdvertises31({}), false); + assert.equal(sellerAdvertises31({ supportedVersions: ['3.0'] }), false); +}); +test('shouldOmit31Fields: client pinned <3.1 always omits', () => { + assert.equal(shouldOmit31Fields('3.0', { supportedVersions: ['3.1'] }), true); +}); +test('shouldOmit31Fields: 3.1 client omits for legacy sellers, sends to 3.1 sellers', () => { + assert.equal(shouldOmit31Fields('3.1.0-rc.14', { supportedVersions: ['3.0'] }), true); + assert.equal(shouldOmit31Fields('3.1.0-rc.14', { buildVersion: '3.1.0' }), false); + assert.equal(shouldOmit31Fields('3.1.0-rc.14', undefined), true); +}); +test('omit31BrandFields strips brand_kit_override only, preserves AdCP 3.0 fields', () => { + assert.deepEqual( + omit31BrandFields({ + domain: 'goldpeaktea.com', + brand_id: 'brand_4045', + industries: ['cpg'], + data_subject_contestation: { email: 'p@goldpeaktea.com' }, + brand_kit_override: { colors: { accent: '#f5ce65' } }, + }), + { + domain: 'goldpeaktea.com', + brand_id: 'brand_4045', + industries: ['cpg'], + data_subject_contestation: { email: 'p@goldpeaktea.com' }, + } + ); +}); +test('omit31BrandFields passes through non-object values untouched', () => { + assert.equal(omit31BrandFields(undefined), undefined); + assert.equal(omit31BrandFields('https://example.com'), 'https://example.com'); +}); diff --git a/test/lib/get-products-webhook-degrade.test.js b/test/lib/get-products-webhook-degrade.test.js new file mode 100644 index 000000000..0621c86a9 --- /dev/null +++ b/test/lib/get-products-webhook-degrade.test.js @@ -0,0 +1,91 @@ +/** + * get_products / get_signals discovery-webhook degradation for pre-3.1 sellers. + * + * Discovery-task push notifications (`push_notification_config`) are an AdCP + * 3.1 feature. When the client is pinned below 3.1, an AUTO-injected discovery + * webhook (from `webhookUrlTemplate`) must degrade to polling, suppressed so + * no `push_notification_config` reaches the wire, rather than throwing. A + * 3.1+ pin still gets the webhook. An EXPLICIT `push_notification_config` + * passed by the caller while hard-pinned <3.1 is misuse and still throws. + */ + +const { describe, it, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); + +const { ProtocolClient, SingleAgentClient } = require('../../dist/lib/index.js'); +const { ProtocolFeatureUnsupportedError } = require('../../dist/lib/errors/index.js'); + +const originalCallTool = ProtocolClient.callTool; + +const agent = { id: 'agent_1', name: 'Agent 1', protocol: 'mcp', agent_uri: 'https://agent.example/mcp/' }; +const TEMPLATE = 'https://buyer.example/webhook/{task_type}/{agent_id}/{operation_id}'; + +function makeClient(adcpVersion) { + const client = new SingleAgentClient(agent, { + adcpVersion, + webhookUrlTemplate: TEMPLATE, + validateFeatures: false, + validation: { requests: 'off', responses: 'off' }, + }); + client.ensureEndpointDiscovered = async () => agent; + client.detectServerVersion = async () => 'v3'; + // Inject caps so getCapabilities() short-circuits (no live fetch) and the + // get_products early-feature check treats the seller as v3. + client.cachedCapabilities = { version: 'v3', majorVersions: [3], supportedVersions: ['3.0'], _synthetic: false }; + return client; +} + +describe('get_products discovery-webhook degradation (pre-3.1)', () => { + afterEach(() => { + ProtocolClient.callTool = originalCallTool; + }); + + it('suppresses the auto-injected webhook for a 3.0-pinned client (no throw, no push_notification_config)', async () => { + const calls = []; + ProtocolClient.callTool = async (_agent, _taskName, _params, options) => { + calls.push(options); + return { status: 'completed', products: [] }; + }; + + const client = makeClient('3.0'); + const result = await client.getProducts({ brief: 'sneakers' }); + + assert.equal(calls.length, 1); + assert.equal(calls[0].webhookUrl, undefined); + // A drift entry is surfaced rather than silently dropping the webhook. + const driftLog = (result.debug_logs ?? []).find(l => l.type === 'pre31_webhook_degraded'); + assert.ok(driftLog, 'expected a pre31_webhook_degraded debug log'); + assert.equal(driftLog.taskName, 'get_products'); + }); + + it('still sends the webhook for a 3.1-pinned client', async () => { + const calls = []; + ProtocolClient.callTool = async (_agent, _taskName, _params, options) => { + calls.push(options); + return { status: 'completed', products: [] }; + }; + + const client = makeClient('3.1.0-rc.14'); + await client.getProducts({ brief: 'sneakers' }); + + assert.equal(calls.length, 1); + assert.match(calls[0].webhookUrl, /^https:\/\/buyer\.example\/webhook\/get_products\/agent_1\//); + }); + + it('throws on an EXPLICIT push_notification_config while hard-pinned <3.1', async () => { + ProtocolClient.callTool = async () => ({ status: 'completed', products: [] }); + + const client = makeClient('3.0'); + await assert.rejects( + () => + client.getProducts({ + brief: 'sneakers', + push_notification_config: { + url: 'https://buyer.example/explicit', + authentication: { schemes: ['HMAC-SHA256'], credentials: 'x'.repeat(32) }, + }, + }), + ProtocolFeatureUnsupportedError + ); + }); +}); diff --git a/test/lib/get-signals-pre31-wholesale.test.js b/test/lib/get-signals-pre31-wholesale.test.js index c248c0635..fab443b94 100644 --- a/test/lib/get-signals-pre31-wholesale.test.js +++ b/test/lib/get-signals-pre31-wholesale.test.js @@ -6,6 +6,7 @@ const path = require('node:path'); const { SingleAgentClient, + ProtocolClient, FeatureUnsupportedError, ProtocolFeatureUnsupportedError, getClientPreflightAdcpError, @@ -169,18 +170,34 @@ describe('get_signals wholesale against pre-3.1 client pin', () => { ); }); - test('webhookUrlTemplate injection throws the same pre-3.1 push_notification_config error', async () => { + test('auto-injected webhookUrlTemplate degrades to polling (no throw, webhook suppressed)', async () => { + // An auto-injected discovery webhook (from webhookUrlTemplate) is the + // library's doing, not the caller's: degrade it to polling for a pre-3.1 + // pin instead of throwing. Contrast with the explicit-config cases above, + // which remain caller misuse and still throw. const client = makePre31Client({ webhookUrlTemplate: 'https://buyer.example.com/adcp-webhook/{task_type}/{agent_id}/{operation_id}', }); - - await assert.rejects( - () => - client.getSignals({ - signal_spec: 'sports fans', - }), - assertPushConfigUnsupported('get_signals') - ); + client.ensureEndpointDiscovered = async () => client.agent; + client.detectServerVersion = async () => 'v3'; + client.cachedCapabilities = { version: 'v3', majorVersions: [3], supportedVersions: ['3.0'], _synthetic: false }; + + const calls = []; + const originalCallTool = ProtocolClient.callTool; + ProtocolClient.callTool = async (_agent, _taskName, _params, options) => { + calls.push(options); + return { status: 'completed', signals: [] }; + }; + try { + const result = await client.getSignals({ signal_spec: 'sports fans' }); + assert.equal(calls.length, 1); + assert.equal(calls[0].webhookUrl, undefined); + const driftLog = (result.debug_logs ?? []).find(l => l.type === 'pre31_webhook_degraded'); + assert.ok(driftLog, 'expected a pre31_webhook_degraded debug log'); + assert.strictEqual(driftLog.taskName, 'get_signals'); + } finally { + ProtocolClient.callTool = originalCallTool; + } }); test('conditional feed version probes are not treated as pre-3.1 unsupported features', async () => { diff --git a/test/lib/per-seller-brand-projection.test.js b/test/lib/per-seller-brand-projection.test.js new file mode 100644 index 000000000..1238419b9 --- /dev/null +++ b/test/lib/per-seller-brand-projection.test.js @@ -0,0 +1,103 @@ +/** + * Per-seller brand-override projection. + * + * The 3.0 version adapters strip `brand_kit_override` (a 3.1-only field) + * from outbound brand references when the negotiated target is pre-3.1. + * `industries` and `data_subject_contestation` are declared in AdCP 3.0 + * and are left on the wire. A 3.1 seller receives the full overrides. + * + * `resolveAdapterKey` returns '3.0' whenever `shouldOmit31Fields` is true + * (client pinned <3.1 OR seller does not advertise 3.1). The adapter emits + * a `pre31_brand_fields_stripped` drift entry when stripping occurs. + */ + +const { test } = require('node:test'); +const assert = require('node:assert'); + +const { getVersionAdapter, resolveAdapterKey } = require('../../dist/lib/adapters/version/index.js'); + +const brand = { + domain: 'goldpeaktea.com', + brand_id: 'b', + industries: ['cpg'], + data_subject_contestation: { email: 'p@goldpeaktea.com' }, + brand_kit_override: { colors: { accent: '#f5ce65' } }, +}; + +const caps30 = { version: 'v3', majorVersions: [3], supportedVersions: ['3.0'], _synthetic: false }; +const caps31 = { version: 'v3', majorVersions: [3], buildVersion: '3.1.0', _synthetic: false }; + +test('resolveAdapterKey: returns 3.0 for pre-3.1 client', () => { + assert.equal(resolveAdapterKey('3.0', caps31), '3.0'); +}); +test('resolveAdapterKey: returns 3.0 for 3.1 client with 3.0 seller', () => { + assert.equal(resolveAdapterKey('3.1.0', caps30), '3.0'); +}); +test('resolveAdapterKey: returns undefined for 3.1 client with 3.1 seller', () => { + assert.equal(resolveAdapterKey('3.1.0', caps31), undefined); +}); + +test('create_media_buy: brand_kit_override stripped, 3.0 fields preserved', () => { + const adapter = getVersionAdapter('3.0', 'create_media_buy'); + const { params, drift } = adapter.adaptRequest({ brand: { ...brand }, idempotency_key: 'k' }); + assert.deepEqual(params.brand, { + domain: 'goldpeaktea.com', + brand_id: 'b', + industries: ['cpg'], + data_subject_contestation: { email: 'p@goldpeaktea.com' }, + }); + assert.equal(drift?.type, 'pre31_brand_fields_stripped'); + assert.deepEqual(drift?.strippedFields, ['brand_kit_override']); +}); + +test('create_media_buy: no strip when brand_kit_override absent', () => { + const adapter = getVersionAdapter('3.0', 'create_media_buy'); + const input = { brand: { domain: 'goldpeaktea.com', brand_id: 'b' }, idempotency_key: 'k' }; + const { params, drift } = adapter.adaptRequest(input); + assert.equal(params, input); + assert.equal(drift, undefined); +}); + +test('get_products: brand_kit_override stripped for 3.0 target', () => { + const adapter = getVersionAdapter('3.0', 'get_products'); + const { params, drift } = adapter.adaptRequest({ brand: { ...brand }, brief: 'x' }); + assert.deepEqual(params.brand, { + domain: 'goldpeaktea.com', + brand_id: 'b', + industries: ['cpg'], + data_subject_contestation: { email: 'p@goldpeaktea.com' }, + }); + assert.ok(drift); +}); + +test('sync_accounts: brand_kit_override stripped at accounts[].brand', () => { + const adapter = getVersionAdapter('3.0', 'sync_accounts'); + const { params, drift } = adapter.adaptRequest({ + accounts: [{ brand: { ...brand }, operator: 'o', billing: 'operator' }], + idempotency_key: 'k', + }); + assert.deepEqual(params.accounts[0].brand, { + domain: 'goldpeaktea.com', + brand_id: 'b', + industries: ['cpg'], + data_subject_contestation: { email: 'p@goldpeaktea.com' }, + }); + assert.equal(params.accounts[0].operator, 'o'); + assert.ok(drift); +}); + +test('sync_accounts: no strip when no account has brand_kit_override', () => { + const adapter = getVersionAdapter('3.0', 'sync_accounts'); + const input = { + accounts: [{ brand: { domain: 'goldpeaktea.com', brand_id: 'b' }, operator: 'o', billing: 'operator' }], + idempotency_key: 'k', + }; + const { params, drift } = adapter.adaptRequest(input); + assert.equal(params, input); + assert.equal(drift, undefined); +}); + +test('adapters return undefined for unregistered tools at 3.0', () => { + assert.equal(getVersionAdapter('3.0', 'list_creative_formats'), undefined); + assert.equal(getVersionAdapter('3.0', 'get_signals'), undefined); +});