From bbbbb239f68d2795fc66f1aa589b96a24bccdc9b Mon Sep 17 00:00:00 2001 From: Nastassia Fulconis Date: Mon, 15 Jun 2026 19:59:54 +0300 Subject: [PATCH 01/10] feat: add AdCP 3.1 field-projection helpers Co-Authored-By: Claude Opus 4.8 --- src/lib/utils/adcp-version-config.ts | 37 ++++++++++++++++++++ test/lib/adcp-version-projection.test.js | 43 ++++++++++++++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 test/lib/adcp-version-projection.test.js diff --git a/src/lib/utils/adcp-version-config.ts b/src/lib/utils/adcp-version-config.ts index f804fa38a..79ed00df7 100644 --- a/src/lib/utils/adcp-version-config.ts +++ b/src/lib/utils/adcp-version-config.ts @@ -118,6 +118,43 @@ 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 AdCP 3.1-only inline-override fields from a BrandReference, leaving the + * identity fields (`domain`, `brand_id`) the seller resolves the brand by. The + * BrandReference object is closed (`additionalProperties: false`) in every + * version, so pre-3.1 sellers reject these fields; brand.json is the canonical + * source the seller falls back to. + */ +export function omit31BrandFields(brand: T): T { + if (!brand || typeof brand !== 'object' || Array.isArray(brand)) return brand; + const { industries, data_subject_contestation, brand_kit_override, ...rest } = + brand as Record; + 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..ddb1691ff --- /dev/null +++ b/test/lib/adcp-version-projection.test.js @@ -0,0 +1,43 @@ +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 3.1 inline overrides, keeps identity', () => { + 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' }, + ); +}); +test('omit31BrandFields passes through non-object values untouched', () => { + assert.equal(omit31BrandFields(undefined), undefined); + assert.equal(omit31BrandFields('https://example.com'), 'https://example.com'); +}); From bb5c71ee9cc6eece009f78aabb48cdd8b625e878 Mon Sep 17 00:00:00 2001 From: Nastassia Fulconis Date: Mon, 15 Jun 2026 20:15:14 +0300 Subject: [PATCH 02/10] feat: strip AdCP 3.1 brand overrides for pre-3.1 sellers Co-Authored-By: Claude Opus 4.8 --- src/lib/core/SingleAgentClient.ts | 56 +++++++++++++-- test/lib/per-seller-brand-projection.test.js | 73 ++++++++++++++++++++ 2 files changed, 124 insertions(+), 5 deletions(-) create mode 100644 test/lib/per-seller-brand-projection.test.js diff --git a/src/lib/core/SingleAgentClient.ts b/src/lib/core/SingleAgentClient.ts index 2d9dc6a9e..59f51562f 100644 --- a/src/lib/core/SingleAgentClient.ts +++ b/src/lib/core/SingleAgentClient.ts @@ -5,7 +5,13 @@ import * as schemas from '../types/schemas.generated'; 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 { + isAdcpVersionSupported, + isPre31AdcpVersion, + omit31BrandFields, + resolveAdcpVersion, + shouldOmit31Fields, +} from '../utils/adcp-version-config'; import type { GetProductsRequest, GetProductsResponse, @@ -1639,6 +1645,11 @@ export class SingleAgentClient { const serverVersion = await this.detectServerVersion(); const adaptedParams = await this.adaptRequestForServerVersion(taskType, normalizedParams); + // Strip AdCP 3.1-only fields (brand inline-overrides) when the negotiated + // target is pre-3.1. Runs after the v2 adapter and after detectServerVersion + // has populated cachedCapabilities, so the decision sees the seller's caps. + const projectedParams = this.projectRequestForSellerVersion(taskType, adaptedParams); + // Symmetric to the pre-adapter v3 pass above: when the adapter // rewrote the request for a v2 server, warn-validate the adapted // shape against the cached v2.5 schema bundle. Drift gets collected @@ -1647,13 +1658,13 @@ export class SingleAgentClient { // the floor and adapter drift would land in production unnoticed. const v25DriftLogs: any[] = []; if (serverVersion === 'v2') { - this.executor.validateAdaptedRequestAgainstV2(taskType, adaptedParams, v25DriftLogs); + this.executor.validateAdaptedRequestAgainstV2(taskType, projectedParams, v25DriftLogs); } let result = await this.executor.executeTask( agent, taskType, - adaptedParams, + projectedParams, inputHandler, options, serverVersion @@ -2046,6 +2057,36 @@ export class SingleAgentClient { }; } + /** + * Strip AdCP 3.1-only brand inline-override fields from a request when the + * negotiated target is pre-3.1 (client pinned <3.1 OR the seller does not + * advertise 3.1). Keeps the brand identity fields (`domain`, `brand_id`) the + * seller resolves the brand by; the closed BrandReference object would + * otherwise be rejected by a pre-3.1 seller. Runs after + * `adaptRequestForServerVersion` so `cachedCapabilities` is populated. + * + * Brand path per tool: `create_media_buy` / `get_products` carry brand at + * the top level; `sync_accounts` carries it at `accounts[].brand` (only on + * provisioning-mode entries — settings-update entries key by `account` and + * have no `brand`, so they pass through untouched). + * + * Not `private` so the projection can be unit-tested directly. + */ + projectRequestForSellerVersion(taskName: string, params: unknown): unknown { + if (!params || typeof params !== 'object') return params; + if (!shouldOmit31Fields(this.resolvedAdcpVersion, this.cachedCapabilities)) return params; + const req = { ...(params as Record) }; + if (taskName === 'create_media_buy' || taskName === 'get_products') { + if (req.brand) req.brand = omit31BrandFields(req.brand); + } + if (taskName === 'sync_accounts' && Array.isArray(req.accounts)) { + req.accounts = (req.accounts as Array>).map(a => + a && typeof a === 'object' && a.brand ? { ...a, brand: omit31BrandFields(a.brand) } : a + ); + } + return req; + } + /** * Adapt request parameters for the detected server version * @@ -2993,18 +3034,23 @@ export class SingleAgentClient { const serverVersion = await this.detectServerVersion(); const adaptedParams = await this.adaptRequestForServerVersion(taskName, normalizedParams); + // Strip AdCP 3.1-only fields (brand inline-overrides) when the negotiated + // target is pre-3.1. Runs after the v2 adapter and after detectServerVersion + // has populated cachedCapabilities, so the decision sees the seller's caps. + const projectedParams = this.projectRequestForSellerVersion(taskName, adaptedParams); + // 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[] = []; if (serverVersion === 'v2') { - this.executor.validateAdaptedRequestAgainstV2(taskName, adaptedParams, v25DriftLogs); + this.executor.validateAdaptedRequestAgainstV2(taskName, projectedParams, v25DriftLogs); } let result = await this.executor.executeTask( agent, taskName, - adaptedParams, + projectedParams, inputHandler, options, serverVersion 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..07ae5ef66 --- /dev/null +++ b/test/lib/per-seller-brand-projection.test.js @@ -0,0 +1,73 @@ +/** + * Per-seller brand-override projection. + * + * The SDK strips AdCP 3.1-only brand inline-override fields + * (`industries`, `data_subject_contestation`, `brand_kit_override`) from + * outbound requests when the negotiated target is pre-3.1 (client pinned + * <3.1 OR the seller does not advertise 3.1). Identity fields (`domain`, + * `brand_id`) are always kept so the seller can resolve the brand from + * brand.json. A 3.1 seller still receives the full overrides. + */ + +const { test } = require('node:test'); +const assert = require('node:assert'); + +const { SingleAgentClient } = require('../../dist/lib/core/SingleAgentClient.js'); + +const agent = { id: 's', name: 's', protocol: 'mcp', agent_uri: 'https://s.example/mcp' }; +const brand = { + domain: 'goldpeaktea.com', + brand_id: 'b', + industries: ['cpg'], + data_subject_contestation: { email: 'p@goldpeaktea.com' }, + brand_kit_override: { colors: { accent: '#f5ce65' } }, +}; + +test('create_media_buy brand: overrides stripped for legacy 3.0 seller', () => { + const c = new SingleAgentClient(agent); + c.cachedCapabilities = { version: 'v3', majorVersions: [3], supportedVersions: ['3.0'], _synthetic: false }; + const out = c.projectRequestForSellerVersion('create_media_buy', { brand: { ...brand }, idempotency_key: 'k' }); + assert.deepEqual(out.brand, { domain: 'goldpeaktea.com', brand_id: 'b' }); +}); + +test('create_media_buy brand: overrides preserved for 3.1 seller', () => { + const c = new SingleAgentClient(agent); + c.cachedCapabilities = { version: 'v3', majorVersions: [3], buildVersion: '3.1.0', _synthetic: false }; + const out = c.projectRequestForSellerVersion('create_media_buy', { brand: { ...brand }, idempotency_key: 'k' }); + assert.deepEqual(out.brand.brand_kit_override, { colors: { accent: '#f5ce65' } }); + assert.deepEqual(out.brand.industries, ['cpg']); +}); + +test('get_products brand: overrides stripped for legacy 3.0 seller', () => { + const c = new SingleAgentClient(agent); + c.cachedCapabilities = { version: 'v3', majorVersions: [3], supportedVersions: ['3.0'], _synthetic: false }; + const out = c.projectRequestForSellerVersion('get_products', { brand: { ...brand }, brief: 'x' }); + assert.deepEqual(out.brand, { domain: 'goldpeaktea.com', brand_id: 'b' }); +}); + +test('sync_accounts brand: overrides stripped at accounts[].brand for 3.0 seller', () => { + const c = new SingleAgentClient(agent); + c.cachedCapabilities = { version: 'v3', majorVersions: [3], supportedVersions: ['3.0'], _synthetic: false }; + const out = c.projectRequestForSellerVersion('sync_accounts', { + accounts: [{ brand: { ...brand }, operator: 'o', billing: 'operator' }], + idempotency_key: 'k', + }); + assert.deepEqual(out.accounts[0].brand, { domain: 'goldpeaktea.com', brand_id: 'b' }); + assert.equal(out.accounts[0].operator, 'o'); +}); + +test('sync_accounts brand: overrides preserved for 3.1 seller', () => { + const c = new SingleAgentClient(agent); + c.cachedCapabilities = { version: 'v3', majorVersions: [3], buildVersion: '3.1.0', _synthetic: false }; + const out = c.projectRequestForSellerVersion('sync_accounts', { + accounts: [{ brand: { ...brand }, operator: 'o', billing: 'operator' }], + idempotency_key: 'k', + }); + assert.deepEqual(out.accounts[0].brand.brand_kit_override, { colors: { accent: '#f5ce65' } }); +}); + +test('projectRequestForSellerVersion passes through non-object params', () => { + const c = new SingleAgentClient(agent); + c.cachedCapabilities = { version: 'v3', majorVersions: [3], supportedVersions: ['3.0'], _synthetic: false }; + assert.equal(c.projectRequestForSellerVersion('get_products', undefined), undefined); +}); From 02eec5e56cf081396bb8e0ccf3996c3bfc0f5c1d Mon Sep 17 00:00:00 2001 From: Nastassia Fulconis Date: Mon, 15 Jun 2026 20:34:10 +0300 Subject: [PATCH 03/10] feat: degrade get_products discovery webhook to polling for pre-3.1 sellers Co-Authored-By: Claude Opus 4.8 --- src/lib/core/SingleAgentClient.ts | 85 ++++++++++++++--- test/lib/get-products-webhook-degrade.test.js | 91 +++++++++++++++++++ test/lib/get-signals-pre31-wholesale.test.js | 35 +++++-- 3 files changed, 189 insertions(+), 22 deletions(-) create mode 100644 test/lib/get-products-webhook-degrade.test.js diff --git a/src/lib/core/SingleAgentClient.ts b/src/lib/core/SingleAgentClient.ts index 59f51562f..bd0ba887c 100644 --- a/src/lib/core/SingleAgentClient.ts +++ b/src/lib/core/SingleAgentClient.ts @@ -1589,6 +1589,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 @@ -1657,6 +1665,7 @@ export class SingleAgentClient { // returns — without that merge the warning would silently drop on // the floor and adapter drift would land in production unnoticed. const v25DriftLogs: any[] = []; + if (webhookDriftLog) v25DriftLogs.push(webhookDriftLog); if (serverVersion === 'v2') { this.executor.validateAdaptedRequestAgainstV2(taskType, projectedParams, v25DriftLogs); } @@ -1666,15 +1675,16 @@ export class SingleAgentClient { taskType, projectedParams, 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]; } @@ -3014,6 +3024,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); @@ -3043,6 +3062,7 @@ export class SingleAgentClient { // Drift gets surfaced via result.metadata.debug_logs so adapter // regressions in production aren't silently swallowed. const v25DriftLogs: any[] = []; + if (webhookDriftLog) v25DriftLogs.push(webhookDriftLog); if (serverVersion === 'v2') { this.executor.validateAdaptedRequestAgainstV2(taskName, projectedParams, v25DriftLogs); } @@ -3052,7 +3072,7 @@ export class SingleAgentClient { taskName, projectedParams, inputHandler, - options, + effectiveOptions, serverVersion ); @@ -3988,12 +4008,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', { @@ -4002,10 +4020,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.', @@ -4018,6 +4038,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, 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..cf571d397 --- /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..03b0b38f8 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 () => { From 1e57767e5580a98e506ee19b78796c6970833c3c Mon Sep 17 00:00:00 2001 From: Nastassia Fulconis Date: Mon, 15 Jun 2026 20:45:12 +0300 Subject: [PATCH 04/10] chore: add changeset for pre-3.1 field projection --- .changeset/pre-3-1-field-projection.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .changeset/pre-3-1-field-projection.md diff --git a/.changeset/pre-3-1-field-projection.md b/.changeset/pre-3-1-field-projection.md new file mode 100644 index 000000000..31fff2b3d --- /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 + +Sellers on AdCP 3.0 reject requests carrying 3.1-only fields. `BrandReference` is a closed object (`additionalProperties: false`) in every version, so the 3.1 inline overrides (`brand_kit_override`, `industries`, `data_subject_contestation`) are rejected by 3.0 sellers on `create_media_buy`, `sync_accounts`, and `get_products`. 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 these 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: + +- The outbound brand reference is reduced to its identity fields (`domain`, `brand_id`); the seller resolves the inline-override subset from `brand.json`. +- 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 (warn, not silent drop). + +The decision is keyed on `shouldOmit31Fields(clientVersion, sellerCapabilities)`, so it is correct for 3.0-pinned callers today and becomes per-seller automatically when a caller pins to 3.1. From e47bd8ea9490a93edb6bb54dbe12de6aad2bf93e Mon Sep 17 00:00:00 2001 From: Nastassia Fulconis Date: Mon, 15 Jun 2026 20:51:50 +0300 Subject: [PATCH 05/10] style: use plain punctuation instead of em dashes in added comments --- src/lib/core/SingleAgentClient.ts | 6 +++--- test/lib/get-products-webhook-degrade.test.js | 4 ++-- test/lib/get-signals-pre31-wholesale.test.js | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/lib/core/SingleAgentClient.ts b/src/lib/core/SingleAgentClient.ts index bd0ba887c..0c39fc84b 100644 --- a/src/lib/core/SingleAgentClient.ts +++ b/src/lib/core/SingleAgentClient.ts @@ -1682,7 +1682,7 @@ export class SingleAgentClient { // Merge collected drift into the executor's debug_logs so adopters // 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 — + // 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) { @@ -2077,7 +2077,7 @@ export class SingleAgentClient { * * Brand path per tool: `create_media_buy` / `get_products` carry brand at * the top level; `sync_accounts` carries it at `accounts[].brand` (only on - * provisioning-mode entries — settings-update entries key by `account` and + * provisioning-mode entries; settings-update entries key by `account` and * have no `brand`, so they pass through untouched). * * Not `private` so the projection can be unit-tested directly. @@ -4021,7 +4021,7 @@ export class SingleAgentClient { } // An EXPLICIT push_notification_config on a discovery task is caller misuse - // while hard-pinned <3.1 — surface it rather than silently dropping the + // 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`. diff --git a/test/lib/get-products-webhook-degrade.test.js b/test/lib/get-products-webhook-degrade.test.js index cf571d397..0621c86a9 100644 --- a/test/lib/get-products-webhook-degrade.test.js +++ b/test/lib/get-products-webhook-degrade.test.js @@ -3,8 +3,8 @@ * * 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 + * 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. */ diff --git a/test/lib/get-signals-pre31-wholesale.test.js b/test/lib/get-signals-pre31-wholesale.test.js index 03b0b38f8..fab443b94 100644 --- a/test/lib/get-signals-pre31-wholesale.test.js +++ b/test/lib/get-signals-pre31-wholesale.test.js @@ -172,7 +172,7 @@ describe('get_signals wholesale against pre-3.1 client pin', () => { 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 + // 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({ From 68b8f38ca5aa2181c47806fcd562c16615741e88 Mon Sep 17 00:00:00 2001 From: Nastassia Fulconis Date: Mon, 15 Jun 2026 21:03:03 +0300 Subject: [PATCH 06/10] fix: bump ws to 8.21.0, fix prettier formatting Resolves high-severity ws DoS vulnerability (GHSA-96hv-2xvq-fx4p). Applies prettier formatting to adcp-version-config.ts and adcp-version-projection.test.js. Co-Authored-By: Claude Sonnet 4.6 --- .changeset/ws-security-patch.md | 5 ++ package-lock.json | 100 ++++++++++++++++++----- package.json | 2 +- src/lib/utils/adcp-version-config.ts | 11 +-- test/lib/adcp-version-projection.test.js | 5 +- 5 files changed, 94 insertions(+), 29 deletions(-) create mode 100644 .changeset/ws-security-patch.md 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 26c4bedc9..42b03155a 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": { @@ -146,10 +146,20 @@ "license": "Python-2.0" }, "node_modules/@apidevtools/json-schema-ref-parser/node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -1717,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", @@ -1820,10 +1831,20 @@ "license": "Python-2.0" }, "node_modules/@redocly/openapi-core/node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -1992,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" } @@ -2127,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", @@ -2337,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" }, @@ -3003,10 +3027,20 @@ "license": "Python-2.0" }, "node_modules/cosmiconfig/node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -3335,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", @@ -3638,6 +3673,7 @@ "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -4164,6 +4200,7 @@ "integrity": "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -4601,10 +4638,20 @@ "license": "Python-2.0" }, "node_modules/json-schema-to-typescript/node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -5579,6 +5626,7 @@ "integrity": "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "pg-connection-string": "^2.13.0", "pg-pool": "^3.14.0", @@ -5941,10 +5989,20 @@ "license": "Python-2.0" }, "node_modules/read-yaml-file/node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -6762,6 +6820,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" }, @@ -6971,6 +7030,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", @@ -7007,6 +7067,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" @@ -7220,9 +7281,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.20.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", - "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -7337,6 +7398,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 2089874bb..e6ed41dfa 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/utils/adcp-version-config.ts b/src/lib/utils/adcp-version-config.ts index 79ed00df7..78155a95a 100644 --- a/src/lib/utils/adcp-version-config.ts +++ b/src/lib/utils/adcp-version-config.ts @@ -119,12 +119,10 @@ export function isPre31AdcpVersion(version: string | undefined): boolean { } /** Does the seller advertise AdCP 3.1+ support (via get_adcp_capabilities)? */ -export function sellerAdvertises31( - caps: { supportedVersions?: string[]; buildVersion?: string } | undefined, -): boolean { +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)); + return (caps.supportedVersions ?? []).some(v => !isPre31AdcpVersion(v)); } /** @@ -135,7 +133,7 @@ export function sellerAdvertises31( */ export function shouldOmit31Fields( resolvedClientVersion: string | undefined, - caps: { supportedVersions?: string[]; buildVersion?: string } | undefined, + caps: { supportedVersions?: string[]; buildVersion?: string } | undefined ): boolean { if (isPre31AdcpVersion(resolvedClientVersion)) return true; return !sellerAdvertises31(caps); @@ -150,8 +148,7 @@ export function shouldOmit31Fields( */ export function omit31BrandFields(brand: T): T { if (!brand || typeof brand !== 'object' || Array.isArray(brand)) return brand; - const { industries, data_subject_contestation, brand_kit_override, ...rest } = - brand as Record; + const { industries, data_subject_contestation, brand_kit_override, ...rest } = brand as Record; return rest as T; } diff --git a/test/lib/adcp-version-projection.test.js b/test/lib/adcp-version-projection.test.js index ddb1691ff..f7a81935b 100644 --- a/test/lib/adcp-version-projection.test.js +++ b/test/lib/adcp-version-projection.test.js @@ -29,12 +29,13 @@ test('shouldOmit31Fields: 3.1 client omits for legacy sellers, sends to 3.1 sell test('omit31BrandFields strips 3.1 inline overrides, keeps identity', () => { assert.deepEqual( omit31BrandFields({ - domain: 'goldpeaktea.com', brand_id: 'brand_4045', + 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' }, + { domain: 'goldpeaktea.com', brand_id: 'brand_4045' } ); }); test('omit31BrandFields passes through non-object values untouched', () => { From b03f4efa2ee1564f86211eaad2ed38eaddbf6208 Mon Sep 17 00:00:00 2001 From: Nastassia Fulconis Date: Mon, 15 Jun 2026 21:30:34 +0300 Subject: [PATCH 07/10] fix: narrow brand strip to brand_kit_override, add drift log `omit31BrandFields` was incorrectly stripping `industries` and `data_subject_contestation`, both of which are declared in AdCP 3.0 and accepted by 3.0 sellers. Only `brand_kit_override` is 3.1-only. `projectRequestForSellerVersion` now returns `{ params, driftLog? }` and emits a `pre31_brand_fields_stripped` drift entry when stripping occurs, consistent with the `pre31_webhook_degraded` pattern and the PR's stated contract that drops are surfaced in `debug_logs`, not silently discarded. Co-Authored-By: Claude Sonnet 4.6 --- src/lib/core/SingleAgentClient.ts | 40 ++++++++++--- src/lib/utils/adcp-version-config.ts | 13 +++-- test/lib/adcp-version-projection.test.js | 9 ++- test/lib/per-seller-brand-projection.test.js | 59 +++++++++++++------- 4 files changed, 86 insertions(+), 35 deletions(-) diff --git a/src/lib/core/SingleAgentClient.ts b/src/lib/core/SingleAgentClient.ts index 0c39fc84b..3c9b35ac8 100644 --- a/src/lib/core/SingleAgentClient.ts +++ b/src/lib/core/SingleAgentClient.ts @@ -1656,7 +1656,7 @@ export class SingleAgentClient { // Strip AdCP 3.1-only fields (brand inline-overrides) when the negotiated // target is pre-3.1. Runs after the v2 adapter and after detectServerVersion // has populated cachedCapabilities, so the decision sees the seller's caps. - const projectedParams = this.projectRequestForSellerVersion(taskType, adaptedParams); + const { params: projectedParams, driftLog: brandDriftLog } = this.projectRequestForSellerVersion(taskType, adaptedParams); // Symmetric to the pre-adapter v3 pass above: when the adapter // rewrote the request for a v2 server, warn-validate the adapted @@ -1666,6 +1666,7 @@ export class SingleAgentClient { // the floor and adapter drift would land in production unnoticed. const v25DriftLogs: any[] = []; if (webhookDriftLog) v25DriftLogs.push(webhookDriftLog); + if (brandDriftLog) v25DriftLogs.push(brandDriftLog); if (serverVersion === 'v2') { this.executor.validateAdaptedRequestAgainstV2(taskType, projectedParams, v25DriftLogs); } @@ -2082,19 +2083,41 @@ export class SingleAgentClient { * * Not `private` so the projection can be unit-tested directly. */ - projectRequestForSellerVersion(taskName: string, params: unknown): unknown { - if (!params || typeof params !== 'object') return params; - if (!shouldOmit31Fields(this.resolvedAdcpVersion, this.cachedCapabilities)) return params; + projectRequestForSellerVersion( + taskName: string, + params: unknown + ): { params: unknown; driftLog?: Record } { + if (!params || typeof params !== 'object') return { params }; + if (!shouldOmit31Fields(this.resolvedAdcpVersion, this.cachedCapabilities)) return { params }; const req = { ...(params as Record) }; + let stripped = false; + const stripBrand = (brand: unknown): unknown => { + const result = omit31BrandFields(brand); + if (result !== brand) stripped = true; + return result; + }; if (taskName === 'create_media_buy' || taskName === 'get_products') { - if (req.brand) req.brand = omit31BrandFields(req.brand); + if (req.brand) req.brand = stripBrand(req.brand); } if (taskName === 'sync_accounts' && Array.isArray(req.accounts)) { req.accounts = (req.accounts as Array>).map(a => - a && typeof a === 'object' && a.brand ? { ...a, brand: omit31BrandFields(a.brand) } : a + a && typeof a === 'object' && a.brand ? { ...a, brand: stripBrand(a.brand) } : a ); } - return req; + const driftLog = stripped + ? { + type: 'pre31_brand_fields_stripped', + message: + `${taskName} brand_kit_override stripped for pre-3.1 seller: ` + + `brand_kit_override 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.`, + timestamp: new Date().toISOString(), + taskName, + strippedFields: ['brand_kit_override'], + clientVersion: this.resolvedAdcpVersion, + } + : undefined; + return { params: req, driftLog }; } /** @@ -3056,13 +3079,14 @@ export class SingleAgentClient { // Strip AdCP 3.1-only fields (brand inline-overrides) when the negotiated // target is pre-3.1. Runs after the v2 adapter and after detectServerVersion // has populated cachedCapabilities, so the decision sees the seller's caps. - const projectedParams = this.projectRequestForSellerVersion(taskName, adaptedParams); + const { params: projectedParams, driftLog: brandDriftLog } = this.projectRequestForSellerVersion(taskName, adaptedParams); // 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[] = []; if (webhookDriftLog) v25DriftLogs.push(webhookDriftLog); + if (brandDriftLog) v25DriftLogs.push(brandDriftLog); if (serverVersion === 'v2') { this.executor.validateAdaptedRequestAgainstV2(taskName, projectedParams, v25DriftLogs); } diff --git a/src/lib/utils/adcp-version-config.ts b/src/lib/utils/adcp-version-config.ts index 78155a95a..65a0391a6 100644 --- a/src/lib/utils/adcp-version-config.ts +++ b/src/lib/utils/adcp-version-config.ts @@ -140,15 +140,16 @@ export function shouldOmit31Fields( } /** - * Strip AdCP 3.1-only inline-override fields from a BrandReference, leaving the - * identity fields (`domain`, `brand_id`) the seller resolves the brand by. The - * BrandReference object is closed (`additionalProperties: false`) in every - * version, so pre-3.1 sellers reject these fields; brand.json is the canonical - * source the seller falls back to. + * 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 { industries, data_subject_contestation, brand_kit_override, ...rest } = brand as Record; + const b = brand as Record; + if (!('brand_kit_override' in b)) return brand; + const { brand_kit_override, ...rest } = b; return rest as T; } diff --git a/test/lib/adcp-version-projection.test.js b/test/lib/adcp-version-projection.test.js index f7a81935b..53892bec4 100644 --- a/test/lib/adcp-version-projection.test.js +++ b/test/lib/adcp-version-projection.test.js @@ -26,7 +26,7 @@ test('shouldOmit31Fields: 3.1 client omits for legacy sellers, sends to 3.1 sell 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 3.1 inline overrides, keeps identity', () => { +test('omit31BrandFields strips brand_kit_override only, preserves AdCP 3.0 fields', () => { assert.deepEqual( omit31BrandFields({ domain: 'goldpeaktea.com', @@ -35,7 +35,12 @@ test('omit31BrandFields strips 3.1 inline overrides, keeps identity', () => { data_subject_contestation: { email: 'p@goldpeaktea.com' }, brand_kit_override: { colors: { accent: '#f5ce65' } }, }), - { domain: 'goldpeaktea.com', brand_id: 'brand_4045' } + { + domain: 'goldpeaktea.com', + brand_id: 'brand_4045', + industries: ['cpg'], + data_subject_contestation: { email: 'p@goldpeaktea.com' }, + } ); }); test('omit31BrandFields passes through non-object values untouched', () => { diff --git a/test/lib/per-seller-brand-projection.test.js b/test/lib/per-seller-brand-projection.test.js index 07ae5ef66..4c626ee11 100644 --- a/test/lib/per-seller-brand-projection.test.js +++ b/test/lib/per-seller-brand-projection.test.js @@ -1,12 +1,14 @@ /** * Per-seller brand-override projection. * - * The SDK strips AdCP 3.1-only brand inline-override fields - * (`industries`, `data_subject_contestation`, `brand_kit_override`) from - * outbound requests when the negotiated target is pre-3.1 (client pinned - * <3.1 OR the seller does not advertise 3.1). Identity fields (`domain`, - * `brand_id`) are always kept so the seller can resolve the brand from - * brand.json. A 3.1 seller still receives the full overrides. + * The SDK strips the AdCP 3.1-only `brand_kit_override` field from outbound + * requests when the negotiated target is pre-3.1 (client pinned <3.1 OR the + * seller does not advertise 3.1). `industries` and `data_subject_contestation` + * are declared in AdCP 3.0 and are left on the wire. Identity fields (`domain`, + * `brand_id`) are always kept. A 3.1 seller receives the full overrides. + * + * `projectRequestForSellerVersion` returns `{ params, driftLog? }`. A + * `pre31_brand_fields_stripped` drift entry is emitted whenever stripping occurs. */ const { test } = require('node:test'); @@ -23,43 +25,61 @@ const brand = { brand_kit_override: { colors: { accent: '#f5ce65' } }, }; -test('create_media_buy brand: overrides stripped for legacy 3.0 seller', () => { +test('create_media_buy brand: brand_kit_override stripped for legacy 3.0 seller, 3.0 fields preserved', () => { const c = new SingleAgentClient(agent); c.cachedCapabilities = { version: 'v3', majorVersions: [3], supportedVersions: ['3.0'], _synthetic: false }; - const out = c.projectRequestForSellerVersion('create_media_buy', { brand: { ...brand }, idempotency_key: 'k' }); - assert.deepEqual(out.brand, { domain: 'goldpeaktea.com', brand_id: 'b' }); + const { params: out, driftLog } = c.projectRequestForSellerVersion('create_media_buy', { brand: { ...brand }, idempotency_key: 'k' }); + assert.deepEqual(out.brand, { + domain: 'goldpeaktea.com', + brand_id: 'b', + industries: ['cpg'], + data_subject_contestation: { email: 'p@goldpeaktea.com' }, + }); + assert.equal(driftLog?.type, 'pre31_brand_fields_stripped'); + assert.deepEqual(driftLog?.strippedFields, ['brand_kit_override']); }); -test('create_media_buy brand: overrides preserved for 3.1 seller', () => { +test('create_media_buy brand: overrides preserved for 3.1 seller, no drift log', () => { const c = new SingleAgentClient(agent); c.cachedCapabilities = { version: 'v3', majorVersions: [3], buildVersion: '3.1.0', _synthetic: false }; - const out = c.projectRequestForSellerVersion('create_media_buy', { brand: { ...brand }, idempotency_key: 'k' }); + const { params: out, driftLog } = c.projectRequestForSellerVersion('create_media_buy', { brand: { ...brand }, idempotency_key: 'k' }); assert.deepEqual(out.brand.brand_kit_override, { colors: { accent: '#f5ce65' } }); assert.deepEqual(out.brand.industries, ['cpg']); + assert.equal(driftLog, undefined); }); -test('get_products brand: overrides stripped for legacy 3.0 seller', () => { +test('get_products brand: brand_kit_override stripped for legacy 3.0 seller', () => { const c = new SingleAgentClient(agent); c.cachedCapabilities = { version: 'v3', majorVersions: [3], supportedVersions: ['3.0'], _synthetic: false }; - const out = c.projectRequestForSellerVersion('get_products', { brand: { ...brand }, brief: 'x' }); - assert.deepEqual(out.brand, { domain: 'goldpeaktea.com', brand_id: 'b' }); + const { params: out } = c.projectRequestForSellerVersion('get_products', { brand: { ...brand }, brief: 'x' }); + assert.deepEqual(out.brand, { + domain: 'goldpeaktea.com', + brand_id: 'b', + industries: ['cpg'], + data_subject_contestation: { email: 'p@goldpeaktea.com' }, + }); }); -test('sync_accounts brand: overrides stripped at accounts[].brand for 3.0 seller', () => { +test('sync_accounts brand: brand_kit_override stripped at accounts[].brand for 3.0 seller', () => { const c = new SingleAgentClient(agent); c.cachedCapabilities = { version: 'v3', majorVersions: [3], supportedVersions: ['3.0'], _synthetic: false }; - const out = c.projectRequestForSellerVersion('sync_accounts', { + const { params: out } = c.projectRequestForSellerVersion('sync_accounts', { accounts: [{ brand: { ...brand }, operator: 'o', billing: 'operator' }], idempotency_key: 'k', }); - assert.deepEqual(out.accounts[0].brand, { domain: 'goldpeaktea.com', brand_id: 'b' }); + assert.deepEqual(out.accounts[0].brand, { + domain: 'goldpeaktea.com', + brand_id: 'b', + industries: ['cpg'], + data_subject_contestation: { email: 'p@goldpeaktea.com' }, + }); assert.equal(out.accounts[0].operator, 'o'); }); test('sync_accounts brand: overrides preserved for 3.1 seller', () => { const c = new SingleAgentClient(agent); c.cachedCapabilities = { version: 'v3', majorVersions: [3], buildVersion: '3.1.0', _synthetic: false }; - const out = c.projectRequestForSellerVersion('sync_accounts', { + const { params: out } = c.projectRequestForSellerVersion('sync_accounts', { accounts: [{ brand: { ...brand }, operator: 'o', billing: 'operator' }], idempotency_key: 'k', }); @@ -69,5 +89,6 @@ test('sync_accounts brand: overrides preserved for 3.1 seller', () => { test('projectRequestForSellerVersion passes through non-object params', () => { const c = new SingleAgentClient(agent); c.cachedCapabilities = { version: 'v3', majorVersions: [3], supportedVersions: ['3.0'], _synthetic: false }; - assert.equal(c.projectRequestForSellerVersion('get_products', undefined), undefined); + const { params } = c.projectRequestForSellerVersion('get_products', undefined); + assert.equal(params, undefined); }); From 8b0801fd7ff4ec09e2f75f8f3ccfd5cf0b55b922 Mon Sep 17 00:00:00 2001 From: Nastassia Fulconis Date: Mon, 15 Jun 2026 21:54:54 +0300 Subject: [PATCH 08/10] style: fix prettier formatting on SingleAgentClient and brand projection test Co-Authored-By: Claude Sonnet 4.6 --- src/lib/core/SingleAgentClient.ts | 10 ++++++++-- test/lib/per-seller-brand-projection.test.js | 10 ++++++++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/lib/core/SingleAgentClient.ts b/src/lib/core/SingleAgentClient.ts index 3c9b35ac8..f85dd379e 100644 --- a/src/lib/core/SingleAgentClient.ts +++ b/src/lib/core/SingleAgentClient.ts @@ -1656,7 +1656,10 @@ export class SingleAgentClient { // Strip AdCP 3.1-only fields (brand inline-overrides) when the negotiated // target is pre-3.1. Runs after the v2 adapter and after detectServerVersion // has populated cachedCapabilities, so the decision sees the seller's caps. - const { params: projectedParams, driftLog: brandDriftLog } = this.projectRequestForSellerVersion(taskType, adaptedParams); + const { params: projectedParams, driftLog: brandDriftLog } = this.projectRequestForSellerVersion( + taskType, + adaptedParams + ); // Symmetric to the pre-adapter v3 pass above: when the adapter // rewrote the request for a v2 server, warn-validate the adapted @@ -3079,7 +3082,10 @@ export class SingleAgentClient { // Strip AdCP 3.1-only fields (brand inline-overrides) when the negotiated // target is pre-3.1. Runs after the v2 adapter and after detectServerVersion // has populated cachedCapabilities, so the decision sees the seller's caps. - const { params: projectedParams, driftLog: brandDriftLog } = this.projectRequestForSellerVersion(taskName, adaptedParams); + const { params: projectedParams, driftLog: brandDriftLog } = this.projectRequestForSellerVersion( + taskName, + adaptedParams + ); // Symmetric warn-only post-adapter pass against the v2.5 schema bundle. // Drift gets surfaced via result.metadata.debug_logs so adapter diff --git a/test/lib/per-seller-brand-projection.test.js b/test/lib/per-seller-brand-projection.test.js index 4c626ee11..c1fdf5653 100644 --- a/test/lib/per-seller-brand-projection.test.js +++ b/test/lib/per-seller-brand-projection.test.js @@ -28,7 +28,10 @@ const brand = { test('create_media_buy brand: brand_kit_override stripped for legacy 3.0 seller, 3.0 fields preserved', () => { const c = new SingleAgentClient(agent); c.cachedCapabilities = { version: 'v3', majorVersions: [3], supportedVersions: ['3.0'], _synthetic: false }; - const { params: out, driftLog } = c.projectRequestForSellerVersion('create_media_buy', { brand: { ...brand }, idempotency_key: 'k' }); + const { params: out, driftLog } = c.projectRequestForSellerVersion('create_media_buy', { + brand: { ...brand }, + idempotency_key: 'k', + }); assert.deepEqual(out.brand, { domain: 'goldpeaktea.com', brand_id: 'b', @@ -42,7 +45,10 @@ test('create_media_buy brand: brand_kit_override stripped for legacy 3.0 seller, test('create_media_buy brand: overrides preserved for 3.1 seller, no drift log', () => { const c = new SingleAgentClient(agent); c.cachedCapabilities = { version: 'v3', majorVersions: [3], buildVersion: '3.1.0', _synthetic: false }; - const { params: out, driftLog } = c.projectRequestForSellerVersion('create_media_buy', { brand: { ...brand }, idempotency_key: 'k' }); + const { params: out, driftLog } = c.projectRequestForSellerVersion('create_media_buy', { + brand: { ...brand }, + idempotency_key: 'k', + }); assert.deepEqual(out.brand.brand_kit_override, { colors: { accent: '#f5ce65' } }); assert.deepEqual(out.brand.industries, ['cpg']); assert.equal(driftLog, undefined); From 779d107334af32a20b1debe5c722c51fac08d99b Mon Sep 17 00:00:00 2001 From: Nastassia Fulconis Date: Mon, 15 Jun 2026 22:17:58 +0300 Subject: [PATCH 09/10] docs: correct changeset prose for pre-3.1 field projection industries and data_subject_contestation are AdCP 3.0 GA fields confirmed in schemas/cache/3.0.12/core/brand-ref.json. Only brand_kit_override is 3.1-only. Update changeset to match the code and clarify that the webhook suppression is client-pin-gated, not per-seller. Co-Authored-By: Claude Sonnet 4.6 --- .changeset/pre-3-1-field-projection.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.changeset/pre-3-1-field-projection.md b/.changeset/pre-3-1-field-projection.md index 31fff2b3d..e0e74baec 100644 --- a/.changeset/pre-3-1-field-projection.md +++ b/.changeset/pre-3-1-field-projection.md @@ -4,12 +4,12 @@ feat: strip AdCP 3.1-only request fields when the negotiated target is pre-3.1 -Sellers on AdCP 3.0 reject requests carrying 3.1-only fields. `BrandReference` is a closed object (`additionalProperties: false`) in every version, so the 3.1 inline overrides (`brand_kit_override`, `industries`, `data_subject_contestation`) are rejected by 3.0 sellers on `create_media_buy`, `sync_accounts`, and `get_products`. Separately, the `get_products` discovery webhook (`push_notification_config`, a 3.1 feature) caused the SDK to throw for pre-3.1 clients. +`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 these 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: +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: -- The outbound brand reference is reduced to its identity fields (`domain`, `brand_id`); the seller resolves the inline-override subset from `brand.json`. +- `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 (warn, not silent drop). +- Both are surfaced as `debug_logs` drift entries (`pre31_brand_fields_stripped`, `pre31_webhook_degraded`) so the drops are visible and not silent. -The decision is keyed on `shouldOmit31Fields(clientVersion, sellerCapabilities)`, so it is correct for 3.0-pinned callers today and becomes per-seller automatically when a caller pins to 3.1. +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. From b8eb145e6d1dacc01d80f85a4ef47840a000b8fe Mon Sep 17 00:00:00 2001 From: Nastassia Fulconis Date: Mon, 15 Jun 2026 22:58:49 +0300 Subject: [PATCH 10/10] refactor: generic version adapter registry for protocol-version request adaptation Replaces the hardcoded projectRequestForSellerVersion method with a registry-based pattern mirroring the existing v2.5 adapter registry. - src/lib/adapters/version/ - VersionAdapter interface + registry with getVersionAdapter(key, tool) and resolveAdapterKey(clientVersion, caps) - src/lib/adapters/version/3.0/ - first concrete set: strips brand_kit_override from create_media_buy, get_products, and sync_accounts[].brand for 3.0 sellers - SingleAgentClient.adaptRequest() - unified sync method replacing the async adaptRequestForServerVersion + projectRequestForSellerVersion two-step; applies wire adapters then schema stripping then protocol-version adapters and returns { params, driftLogs[] } for both call sites Adding a future version transition (e.g. 3.2 to 3.1) means adding a src/lib/adapters/version/3.1/ directory and registering it -- nothing else changes. Co-Authored-By: Claude Sonnet 4.6 --- src/lib/adapters/version/3.0/brand-fields.ts | 29 +++ src/lib/adapters/version/3.0/index.ts | 9 + src/lib/adapters/version/3.0/sync-accounts.ts | 29 +++ src/lib/adapters/version/index.ts | 59 +++++ src/lib/adapters/version/types.ts | 33 +++ src/lib/core/SingleAgentClient.ts | 237 ++++++++---------- test/lib/per-seller-brand-projection.test.js | 107 ++++---- 7 files changed, 314 insertions(+), 189 deletions(-) create mode 100644 src/lib/adapters/version/3.0/brand-fields.ts create mode 100644 src/lib/adapters/version/3.0/index.ts create mode 100644 src/lib/adapters/version/3.0/sync-accounts.ts create mode 100644 src/lib/adapters/version/index.ts create mode 100644 src/lib/adapters/version/types.ts 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 f85dd379e..0c7314e47 100644 --- a/src/lib/core/SingleAgentClient.ts +++ b/src/lib/core/SingleAgentClient.ts @@ -5,13 +5,8 @@ import * as schemas from '../types/schemas.generated'; import type { AgentConfig } from '../types'; import { ADCP_ENVELOPE_FIELDS } from '../types/adcp'; import { parseAdcpMajorVersion, type AdcpVersion } from '../version'; -import { - isAdcpVersionSupported, - isPre31AdcpVersion, - omit31BrandFields, - resolveAdcpVersion, - shouldOmit31Fields, -} from '../utils/adcp-version-config'; +import { isAdcpVersionSupported, isPre31AdcpVersion, resolveAdcpVersion } from '../utils/adcp-version-config'; +import { getVersionAdapter, resolveAdapterKey } from '../adapters/version'; import type { GetProductsRequest, GetProductsResponse, @@ -1649,16 +1644,12 @@ 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); - - // Strip AdCP 3.1-only fields (brand inline-overrides) when the negotiated - // target is pre-3.1. Runs after the v2 adapter and after detectServerVersion - // has populated cachedCapabilities, so the decision sees the seller's caps. - const { params: projectedParams, driftLog: brandDriftLog } = this.projectRequestForSellerVersion( + const { params: adaptedParams, driftLogs: adaptDriftLogs } = this.adaptRequest( taskType, - adaptedParams + normalizedParams, + serverVersion ); // Symmetric to the pre-adapter v3 pass above: when the adapter @@ -1667,17 +1658,16 @@ 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 (brandDriftLog) v25DriftLogs.push(brandDriftLog); if (serverVersion === 'v2') { - this.executor.validateAdaptedRequestAgainstV2(taskType, projectedParams, v25DriftLogs); + this.executor.validateAdaptedRequestAgainstV2(taskType, adaptedParams, v25DriftLogs); } let result = await this.executor.executeTask( agent, taskType, - projectedParams, + adaptedParams, inputHandler, effectiveOptions, serverVersion @@ -2072,69 +2062,24 @@ export class SingleAgentClient { } /** - * Strip AdCP 3.1-only brand inline-override fields from a request when the - * negotiated target is pre-3.1 (client pinned <3.1 OR the seller does not - * advertise 3.1). Keeps the brand identity fields (`domain`, `brand_id`) the - * seller resolves the brand by; the closed BrandReference object would - * otherwise be rejected by a pre-3.1 seller. Runs after - * `adaptRequestForServerVersion` so `cachedCapabilities` is populated. - * - * Brand path per tool: `create_media_buy` / `get_products` carry brand at - * the top level; `sync_accounts` carries it at `accounts[].brand` (only on - * provisioning-mode entries; settings-update entries key by `account` and - * have no `brand`, so they pass through untouched). - * - * Not `private` so the projection can be unit-tested directly. + * 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. */ - projectRequestForSellerVersion( - taskName: string, - params: unknown - ): { params: unknown; driftLog?: Record } { - if (!params || typeof params !== 'object') return { params }; - if (!shouldOmit31Fields(this.resolvedAdcpVersion, this.cachedCapabilities)) return { params }; - const req = { ...(params as Record) }; - let stripped = false; - const stripBrand = (brand: unknown): unknown => { - const result = omit31BrandFields(brand); - if (result !== brand) stripped = true; - return result; - }; - if (taskName === 'create_media_buy' || taskName === 'get_products') { - if (req.brand) req.brand = stripBrand(req.brand); - } - if (taskName === 'sync_accounts' && Array.isArray(req.accounts)) { - req.accounts = (req.accounts as Array>).map(a => - a && typeof a === 'object' && a.brand ? { ...a, brand: stripBrand(a.brand) } : a - ); - } - const driftLog = stripped - ? { - type: 'pre31_brand_fields_stripped', - message: - `${taskName} brand_kit_override stripped for pre-3.1 seller: ` + - `brand_kit_override 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.`, - timestamp: new Date().toISOString(), - taskName, - strippedFields: ['brand_kit_override'], - clientVersion: this.resolvedAdcpVersion, - } - : undefined; - return { params: req, driftLog }; - } - - /** - * Adapt request parameters for the detected server version - * - * Converts v3-style requests to v2 format when talking to v2 servers. - */ - 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) @@ -2142,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. @@ -2167,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 }; } /** @@ -3074,33 +3043,27 @@ 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); - - // Strip AdCP 3.1-only fields (brand inline-overrides) when the negotiated - // target is pre-3.1. Runs after the v2 adapter and after detectServerVersion - // has populated cachedCapabilities, so the decision sees the seller's caps. - const { params: projectedParams, driftLog: brandDriftLog } = this.projectRequestForSellerVersion( + const { params: adaptedParams, driftLogs: adaptDriftLogs } = this.adaptRequest( taskName, - adaptedParams + 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 (brandDriftLog) v25DriftLogs.push(brandDriftLog); if (serverVersion === 'v2') { - this.executor.validateAdaptedRequestAgainstV2(taskName, projectedParams, v25DriftLogs); + this.executor.validateAdaptedRequestAgainstV2(taskName, adaptedParams, v25DriftLogs); } let result = await this.executor.executeTask( agent, taskName, - projectedParams, + adaptedParams, inputHandler, effectiveOptions, serverVersion @@ -3718,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) @@ -4277,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/test/lib/per-seller-brand-projection.test.js b/test/lib/per-seller-brand-projection.test.js index c1fdf5653..1238419b9 100644 --- a/test/lib/per-seller-brand-projection.test.js +++ b/test/lib/per-seller-brand-projection.test.js @@ -1,22 +1,21 @@ /** * Per-seller brand-override projection. * - * The SDK strips the AdCP 3.1-only `brand_kit_override` field from outbound - * requests when the negotiated target is pre-3.1 (client pinned <3.1 OR the - * seller does not advertise 3.1). `industries` and `data_subject_contestation` - * are declared in AdCP 3.0 and are left on the wire. Identity fields (`domain`, - * `brand_id`) are always kept. A 3.1 seller receives the full overrides. + * 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. * - * `projectRequestForSellerVersion` returns `{ params, driftLog? }`. A - * `pre31_brand_fields_stripped` drift entry is emitted whenever stripping occurs. + * `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 { SingleAgentClient } = require('../../dist/lib/core/SingleAgentClient.js'); +const { getVersionAdapter, resolveAdapterKey } = require('../../dist/lib/adapters/version/index.js'); -const agent = { id: 's', name: 's', protocol: 'mcp', agent_uri: 'https://s.example/mcp' }; const brand = { domain: 'goldpeaktea.com', brand_id: 'b', @@ -25,76 +24,80 @@ const brand = { brand_kit_override: { colors: { accent: '#f5ce65' } }, }; -test('create_media_buy brand: brand_kit_override stripped for legacy 3.0 seller, 3.0 fields preserved', () => { - const c = new SingleAgentClient(agent); - c.cachedCapabilities = { version: 'v3', majorVersions: [3], supportedVersions: ['3.0'], _synthetic: false }; - const { params: out, driftLog } = c.projectRequestForSellerVersion('create_media_buy', { - brand: { ...brand }, - idempotency_key: 'k', - }); - assert.deepEqual(out.brand, { +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(driftLog?.type, 'pre31_brand_fields_stripped'); - assert.deepEqual(driftLog?.strippedFields, ['brand_kit_override']); + assert.equal(drift?.type, 'pre31_brand_fields_stripped'); + assert.deepEqual(drift?.strippedFields, ['brand_kit_override']); }); -test('create_media_buy brand: overrides preserved for 3.1 seller, no drift log', () => { - const c = new SingleAgentClient(agent); - c.cachedCapabilities = { version: 'v3', majorVersions: [3], buildVersion: '3.1.0', _synthetic: false }; - const { params: out, driftLog } = c.projectRequestForSellerVersion('create_media_buy', { - brand: { ...brand }, - idempotency_key: 'k', - }); - assert.deepEqual(out.brand.brand_kit_override, { colors: { accent: '#f5ce65' } }); - assert.deepEqual(out.brand.industries, ['cpg']); - assert.equal(driftLog, undefined); +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: brand_kit_override stripped for legacy 3.0 seller', () => { - const c = new SingleAgentClient(agent); - c.cachedCapabilities = { version: 'v3', majorVersions: [3], supportedVersions: ['3.0'], _synthetic: false }; - const { params: out } = c.projectRequestForSellerVersion('get_products', { brand: { ...brand }, brief: 'x' }); - assert.deepEqual(out.brand, { +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: brand_kit_override stripped at accounts[].brand for 3.0 seller', () => { - const c = new SingleAgentClient(agent); - c.cachedCapabilities = { version: 'v3', majorVersions: [3], supportedVersions: ['3.0'], _synthetic: false }; - const { params: out } = c.projectRequestForSellerVersion('sync_accounts', { +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(out.accounts[0].brand, { + assert.deepEqual(params.accounts[0].brand, { domain: 'goldpeaktea.com', brand_id: 'b', industries: ['cpg'], data_subject_contestation: { email: 'p@goldpeaktea.com' }, }); - assert.equal(out.accounts[0].operator, 'o'); + assert.equal(params.accounts[0].operator, 'o'); + assert.ok(drift); }); -test('sync_accounts brand: overrides preserved for 3.1 seller', () => { - const c = new SingleAgentClient(agent); - c.cachedCapabilities = { version: 'v3', majorVersions: [3], buildVersion: '3.1.0', _synthetic: false }; - const { params: out } = c.projectRequestForSellerVersion('sync_accounts', { - accounts: [{ brand: { ...brand }, operator: 'o', billing: 'operator' }], +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', - }); - assert.deepEqual(out.accounts[0].brand.brand_kit_override, { colors: { accent: '#f5ce65' } }); + }; + const { params, drift } = adapter.adaptRequest(input); + assert.equal(params, input); + assert.equal(drift, undefined); }); -test('projectRequestForSellerVersion passes through non-object params', () => { - const c = new SingleAgentClient(agent); - c.cachedCapabilities = { version: 'v3', majorVersions: [3], supportedVersions: ['3.0'], _synthetic: false }; - const { params } = c.projectRequestForSellerVersion('get_products', undefined); - assert.equal(params, 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); });