Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/pre-3-1-field-projection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@adcp/sdk': minor
---

feat: strip AdCP 3.1-only request fields when the negotiated target is pre-3.1

`BrandReference` is a closed object (`additionalProperties: false`) in every AdCP version. The 3.1 inline override `brand_kit_override` was added in AdCP 3.1 and does not exist in the 3.0 schema — 3.0 sellers reject requests carrying it. `industries` and `data_subject_contestation` are declared in AdCP 3.0 GA and are accepted by 3.0 sellers; they are left on the wire. Separately, the `get_products` discovery webhook (`push_notification_config`, a 3.1 feature) caused the SDK to throw for pre-3.1 clients.

The client now omits 3.1-only fields when the negotiated target is pre-3.1 (the client is pinned below 3.1, or the seller does not advertise 3.1 via `get_adcp_capabilities`), degrading gracefully:

- `brand_kit_override` is stripped from outbound brand references on `create_media_buy`, `sync_accounts`, and `get_products`; identity fields (`domain`, `brand_id`) and 3.0 fields (`industries`, `data_subject_contestation`) are preserved.
- The auto-injected `get_products` discovery webhook is skipped (results are polled via `tasks/get`) instead of throwing. An explicit caller-supplied `push_notification_config` on a pre-3.1 client still throws (unchanged).
- Both are surfaced as `debug_logs` drift entries (`pre31_brand_fields_stripped`, `pre31_webhook_degraded`) so the drops are visible and not silent.

The brand strip is keyed on `shouldOmit31Fields(clientVersion, sellerCapabilities)` — correct for 3.0-pinned callers today and per-seller when a caller pins to 3.1. The webhook suppression is keyed on the client pin only (`isPre31AdcpVersion`), since suppression runs before `detectServerVersion` populates seller caps.
5 changes: 5 additions & 0 deletions .changeset/ws-security-patch.md
Original file line number Diff line number Diff line change
@@ -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).
14 changes: 13 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
29 changes: 29 additions & 0 deletions src/lib/adapters/version/3.0/brand-fields.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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,
};
9 changes: 9 additions & 0 deletions src/lib/adapters/version/3.0/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { createMediaBuyAdapter, getProductsAdapter } from './brand-fields';
import { syncAccountsAdapter } from './sync-accounts';
import type { VersionAdapter } from '../types';

export const v30Adapters: ReadonlyArray<VersionAdapter> = [
createMediaBuyAdapter,
getProductsAdapter,
syncAccountsAdapter,
];
29 changes: 29 additions & 0 deletions src/lib/adapters/version/3.0/sync-accounts.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
if (!Array.isArray(req.accounts)) return { params };
let stripped = false;
const accounts = (req.accounts as Array<Record<string, unknown>>).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 };
},
};
59 changes: 59 additions & 0 deletions src/lib/adapters/version/index.ts
Original file line number Diff line number Diff line change
@@ -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/<target>/` directory with per-tool adapter modules.
* 2. Collect them in `version/<target>/index.ts` as a `ReadonlyArray<VersionAdapter>`.
* 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<string, ReadonlyMap<string, VersionAdapter>>();

function register(version: string, adapters: ReadonlyArray<VersionAdapter>): 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() ?? [])];
}
33 changes: 33 additions & 0 deletions src/lib/adapters/version/types.ts
Original file line number Diff line number Diff line change
@@ -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/<target>/`) 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 };
}
Loading
Loading