diff --git a/.changeset/experimental-features-helper.md b/.changeset/experimental-features-helper.md new file mode 100644 index 000000000..e6a3e0faf --- /dev/null +++ b/.changeset/experimental-features-helper.md @@ -0,0 +1,9 @@ +--- +'@adcp/client': minor +--- + +Add `experimental_features` support on capabilities (adcp-client#627). + +`AdcpCapabilities` now carries an `experimentalFeatures?: string[]` field populated from the AdCP 3.0 GA `experimental_features` envelope on `get_adcp_capabilities` responses. New helper `supportsExperimentalFeature(caps, id)` lets consumers gate reliance on `x-status: experimental` surfaces (`brand.rights_lifecycle`, `governance.campaign`, `trusted_match.core`, etc.) on an explicit seller opt-in. `resolveFeature` handles the `experimental:` namespace so `require()`/`supports()` flows work the same way they do for `ext:` extensions. + +The `custom` vendor-pricing variant and the `per_unit` catchup from AdCP 3.0 GA were already picked up in the previous types regeneration — no type-surface changes ship with this release. diff --git a/.changeset/idempotency-auto-inject.md b/.changeset/idempotency-auto-inject.md new file mode 100644 index 000000000..f4289a32c --- /dev/null +++ b/.changeset/idempotency-auto-inject.md @@ -0,0 +1,9 @@ +--- +'@adcp/client': patch +--- + +Auto-inject `idempotency_key` on mutating storyboard requests and untyped `executeTask` calls (adcp-client#625). + +The storyboard runner now mints a UUID v4 `idempotency_key` on any mutating step whose `sample_request` omits one — matching how a real buyer operates, so compliance storyboards exercise handler logic rather than short-circuiting on the server's required-field check. Auto-injection applies to `expect_error` steps too, so scenarios that expect specific failures (GOVERNANCE_DENIED, UNAUTHORIZED, brand_mismatch, etc.) reach the error path they named instead of hitting INVALID_REQUEST first. Storyboards that intentionally test the server's missing-key rejection opt out with the new `step.omit_idempotency_key: true` flag. + +The underlying `normalizeRequestParams` helper now derives its mutating-task set from the Zod request schemas (`MUTATING_TASKS` in `utils/idempotency`) rather than a hand-maintained list. The Zod-derived set adds auto-injection for `acquire_rights`, `update_media_buy`, `si_initiate_session`, `si_send_message`, `build_creative`, and the property / collection / content-standards writes — all of which the spec declares as mutating but the hand-maintained list was missing. Any caller using `client.executeTask(, params)` — typed or untyped — now receives the same auto-injected key the typed methods already minted via `executeAndHandle`. diff --git a/src/lib/index.ts b/src/lib/index.ts index 7d64b70d5..bee14d3ae 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -728,6 +728,7 @@ export { requiresOperatorAuth, requiresAccountForProducts, supportsSandbox, + supportsExperimentalFeature, resolveFeature, listDeclaredFeatures, MEDIA_BUY_TOOLS, diff --git a/src/lib/testing/storyboard/runner.ts b/src/lib/testing/storyboard/runner.ts index 216f2f44d..faef38c4c 100644 --- a/src/lib/testing/storyboard/runner.ts +++ b/src/lib/testing/storyboard/runner.ts @@ -21,7 +21,7 @@ import { import { runValidations, type ValidationContext } from './validations'; import { buildRequest, hasRequestBuilder } from './request-builder'; import { resolveAccount, resolveBrand } from '../client'; -import { isMutatingTask } from '../../utils/idempotency'; +import { isMutatingTask, generateIdempotencyKey } from '../../utils/idempotency'; import { PROBE_TASKS, probeProtectedResourceMetadata, @@ -658,6 +658,14 @@ async function executeStep( // when individual builders or sample_request YAML omit brand. request = applyBrandInvariant(request, options); + // Mutating AdCP requests require idempotency_key per spec. Storyboard + // yamls generally omit it so authors don't have to remember it on every + // mutating step — mint one here on the runner's behalf, matching how a + // real buyer would operate. Suppressed when the step expects a missing-key + // error (see `testsMissingIdempotencyKey` below) so that compliance + // surfaces can still exercise the server's required-field check. + request = applyIdempotencyInvariant(request, effectiveStep.task, step); + // Detect unresolved $context placeholders — a prior step likely failed // and didn't produce the expected output. Skip rather than sending garbage. const unresolvedVars = findUnresolvedContextVars(request); @@ -688,15 +696,12 @@ async function executeStep( // + `WWW-Authenticate` header for http_* validations. // // Tests for envelope validation on mutating tasks (e.g., "missing - // idempotency_key returns INVALID_REQUEST") need to suppress the AdCP - // client's auto-inject — otherwise the client helpfully generates a - // UUID and the server never sees a missing-key request. Narrow trigger: - // the step expects an error, the task is mutating, and the request - // doesn't provide idempotency_key. - const testsMissingIdempotencyKey = - step.expect_error === true && - isMutatingTask(effectiveStep.task) && - (request as Record).idempotency_key === undefined; + // idempotency_key returns INVALID_REQUEST") set `step.omit_idempotency_key` + // to suppress both the runner's `applyIdempotencyInvariant` (above) and the + // AdCP client's auto-inject — otherwise the SDK helpfully generates a UUID + // and the server never sees a missing-key request. Paired flags so the two + // layers agree; see `applyIdempotencyInvariant` for the runner-level skip. + const testsMissingIdempotencyKey = step.omit_idempotency_key === true && isMutatingTask(effectiveStep.task); let taskResult: TaskResult | undefined; let stepResult: { duration_ms: number; error?: string; passed: boolean }; @@ -1153,6 +1158,32 @@ export function applyBrandInvariant( return result; } +/** + * Mint an `idempotency_key` for mutating storyboard requests when one wasn't + * supplied. Storyboard `sample_request` blocks generally omit it; the runner + * fills it in so the server's required-field check doesn't short-circuit the + * handler under test, including on `expect_error` steps that name specific + * failure modes (GOVERNANCE_DENIED, UNAUTHORIZED, brand_mismatch, etc.). + * + * Skipped when: + * - `step.omit_idempotency_key === true` — the scenario is explicitly + * exercising the server's missing-key rejection path. + * - the task isn't mutating per {@link MUTATING_TASKS}. + * - the request already carries a key — typically a + * `$generate:uuid_v4#alias` the context injector has resolved to a + * concrete UUID for replay scenarios, or a BYOK key supplied inline. + */ +export function applyIdempotencyInvariant( + request: Record, + taskName: string, + step: StoryboardStep +): Record { + if (step.omit_idempotency_key === true) return request; + if (!isMutatingTask(taskName)) return request; + if (typeof request.idempotency_key === 'string' && request.idempotency_key.length > 0) return request; + return { ...request, idempotency_key: generateIdempotencyKey() }; +} + // ──────────────────────────────────────────────────────────── // Helpers // ──────────────────────────────────────────────────────────── diff --git a/src/lib/testing/storyboard/types.ts b/src/lib/testing/storyboard/types.ts index 2718db9c4..3730be3de 100644 --- a/src/lib/testing/storyboard/types.ts +++ b/src/lib/testing/storyboard/types.ts @@ -132,6 +132,18 @@ export interface StoryboardStep { stateful?: boolean; /** When true, the step passes if the task returns an error */ expect_error?: boolean; + /** + * When true, suppress the runner's `idempotency_key` auto-injection on a + * mutating step so the storyboard can exercise the server's missing-key + * rejection path. The runner also disables the SDK's client-side + * auto-inject for this step so the request reaches the wire without a key. + * + * Default (false) matches buyer-agent behavior: every mutating request + * carries a fresh UUID v4 so handlers under test run against the actual + * error path the storyboard names (GOVERNANCE_DENIED, UNAUTHORIZED, etc.) + * rather than short-circuiting on `INVALID_REQUEST: idempotency_key`. + */ + omit_idempotency_key?: boolean; /** Tool name required for this step to run. Skipped if agent lacks it. */ requires_tool?: string; /** Explicit context extraction rules (supplements convention-based extractors) */ diff --git a/src/lib/utils/capabilities.ts b/src/lib/utils/capabilities.ts index c5a9652ea..a49e03528 100644 --- a/src/lib/utils/capabilities.ts +++ b/src/lib/utils/capabilities.ts @@ -142,6 +142,17 @@ export interface AdcpCapabilities { /** Supported extension namespaces (e.g., 'scope3', 'garm') */ extensions: string[]; + /** + * Experimental AdCP surfaces this agent implements. Dot-namespaced feature + * ids (e.g. `brand.rights_lifecycle`, `governance.campaign`, `trusted_match.core`) + * sellers declare when they opt into surfaces whose schemas carry + * `x-status: experimental`. Consumers should gate any reliance on + * experimental fields on presence of the matching id here. + * + * See https://adcontextprotocol.org/docs/reference/experimental-status + */ + experimentalFeatures?: string[]; + /** Publisher domains covered by this agent */ publisherDomains?: string[]; @@ -393,6 +404,9 @@ export function parseCapabilitiesResponse(response: any): AdcpCapabilities { creative, idempotency, extensions: response.extensions_supported ?? [], + experimentalFeatures: Array.isArray(response.experimental_features) + ? response.experimental_features.filter((f: unknown): f is string => typeof f === 'string') + : undefined, publisherDomains: response.media_buy?.portfolio?.publisher_domains, channels: response.media_buy?.portfolio?.channels, lastUpdated: response.last_updated, @@ -450,6 +464,27 @@ export function supportsSandbox(capabilities: AdcpCapabilities): boolean { return capabilities.account?.sandbox ?? false; } +/** + * Check if the agent has opted into a specific experimental AdCP surface. + * + * Experimental surfaces carry `x-status: experimental` in the spec and their + * fields may change between minor releases. Consumers should gate any + * reliance on experimental fields on a positive check here. + * + * @param capabilities — normalized capabilities from `parseCapabilitiesResponse` + * @param featureId — dot-namespaced id (e.g. `brand.rights_lifecycle`) + * + * @example + * ```ts + * if (supportsExperimentalFeature(caps, 'brand.rights_lifecycle')) { + * // safe to call acquire_rights / release_rights and read their responses + * } + * ``` + */ +export function supportsExperimentalFeature(capabilities: AdcpCapabilities, featureId: string): boolean { + return capabilities.experimentalFeatures?.includes(featureId) ?? false; +} + /** * Feature name that can be checked via supports()/require(). * @@ -567,6 +602,12 @@ export function resolveFeature(capabilities: AdcpCapabilities, feature: FeatureN return capabilities.extensions.includes(extName); } + // Experimental-surface check (e.g., 'experimental:brand.rights_lifecycle') + if (feature.startsWith('experimental:')) { + const featureId = feature.slice('experimental:'.length); + return supportsExperimentalFeature(capabilities, featureId); + } + // Targeting check (e.g., 'targeting.geo_countries') if (feature.startsWith('targeting.')) { const targetingKey = feature.slice(10); @@ -614,6 +655,11 @@ export function listDeclaredFeatures(capabilities: AdcpCapabilities): string[] { features.push(`ext:${ext}`); } + // Experimental surfaces + for (const id of capabilities.experimentalFeatures ?? []) { + features.push(`experimental:${id}`); + } + // Targeting (from raw response) const targeting = getRawNested(capabilities._raw, 'media_buy', 'execution', 'targeting'); if (targeting && typeof targeting === 'object') { diff --git a/src/lib/utils/request-normalizer.ts b/src/lib/utils/request-normalizer.ts index 1be71604e..b5086741f 100644 --- a/src/lib/utils/request-normalizer.ts +++ b/src/lib/utils/request-normalizer.ts @@ -6,35 +6,9 @@ * deprecation warning via warnOnce(). */ -import { randomUUID } from 'crypto'; - import { brandManifestToBrandReference, promotedProductsToCatalog } from '../types/compat'; import { warnOnce } from './deprecation'; - -/** - * Task types whose request schema requires `idempotency_key` per AdCP spec. - * When the caller omits one, the normalizer mints a fresh UUID — the spec - * requires a per-(seller, request) unique value and auto-generation gives - * that by construction for buyers that don't track their own keys. - */ -const TASKS_REQUIRING_IDEMPOTENCY_KEY: ReadonlySet = new Set([ - 'activate_signal', - 'calibrate_content', - 'create_media_buy', - 'delete_collection_list', - 'delete_property_list', - 'log_event', - 'provide_performance_feedback', - 'report_plan_outcome', - 'report_usage', - 'sync_accounts', - 'sync_audiences', - 'sync_catalogs', - 'sync_creatives', - 'sync_event_sources', - 'sync_governance', - 'sync_plans', -]); +import { MUTATING_TASKS, generateIdempotencyKey } from './idempotency'; /** * Normalize a single package's params for backward compatibility. @@ -95,16 +69,18 @@ export function normalizeRequestParams( // ── idempotency_key auto-generation ── // Tasks that mutate state require a caller-supplied idempotency_key per - // AdCP spec. When the caller omits it, mint a fresh UUID v4. Most buyer + // AdCP spec. When the caller omits one, mint a fresh UUID v4. Most buyer // code never needs to track keys of its own — retries via a kept-around // key are the less-common path, and those callers supply their own. // `opts.skipIdempotencyAutoInject` disables this for compliance testing. + // MUTATING_TASKS is derived from the Zod request schemas at module load + // so this stays in sync with the upstream spec — no hand-maintained list. if ( !opts.skipIdempotencyAutoInject && - TASKS_REQUIRING_IDEMPOTENCY_KEY.has(taskType) && + MUTATING_TASKS.has(taskType) && (typeof normalized.idempotency_key !== 'string' || normalized.idempotency_key.length === 0) ) { - normalized.idempotency_key = randomUUID(); + normalized.idempotency_key = generateIdempotencyKey(); } // ── Universal shims (all tools) ── diff --git a/test/lib/storyboard-idempotency-invariant.test.js b/test/lib/storyboard-idempotency-invariant.test.js new file mode 100644 index 000000000..27eb4751d --- /dev/null +++ b/test/lib/storyboard-idempotency-invariant.test.js @@ -0,0 +1,312 @@ +/** + * Storyboard runner: idempotency_key invariant. + * + * Issue #625 — AdCP v3 requires `idempotency_key` on every mutating request. + * Storyboard `sample_request` blocks generally omit it, so the SDK (or the + * server) would reject the request with INVALID_REQUEST before the handler + * runs, masking real handler behavior. The runner now auto-injects a fresh + * UUID on mutating steps that don't supply one. Missing-key error scenarios + * (`expect_error: true`) still flow through without a key so the server's + * required-field check is exercised. + */ + +const { describe, test, it } = require('node:test'); +const assert = require('node:assert'); +const http = require('http'); + +const { applyIdempotencyInvariant, runStoryboard } = require('../../dist/lib/testing/storyboard/runner.js'); + +const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +describe('applyIdempotencyInvariant', () => { + test('injects a UUID v4 on mutating tasks when the request omits one', () => { + const result = applyIdempotencyInvariant({ name: 'p1' }, 'create_property_list', {}); + assert.match(result.idempotency_key, UUID_V4); + }); + + test('injects on mutating expect_error steps so the server reaches the error path the storyboard named (e.g. GOVERNANCE_DENIED, UNAUTHORIZED)', () => { + const result = applyIdempotencyInvariant({ campaign: { id: 'c1' } }, 'acquire_rights', { expect_error: true }); + assert.match(result.idempotency_key, UUID_V4); + }); + + test('passes through read-only tasks untouched', () => { + const input = { filter: 'x' }; + const result = applyIdempotencyInvariant(input, 'get_products', {}); + assert.strictEqual(result, input); + assert.strictEqual(result.idempotency_key, undefined); + }); + + test('preserves a caller-supplied idempotency_key (BYOK)', () => { + const result = applyIdempotencyInvariant( + { idempotency_key: 'byok-1234567890abcdef', name: 'p1' }, + 'create_property_list', + {} + ); + assert.strictEqual(result.idempotency_key, 'byok-1234567890abcdef'); + }); + + test('preserves a concrete UUID pre-resolved by the context injector from $generate:uuid_v4#alias (replay scenarios)', () => { + const preResolved = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee'; + const result = applyIdempotencyInvariant({ idempotency_key: preResolved, name: 'p1' }, 'create_property_list', {}); + assert.strictEqual(result.idempotency_key, preResolved); + }); + + test('treats empty-string idempotency_key as unset and injects a fresh UUID', () => { + const result = applyIdempotencyInvariant({ idempotency_key: '', name: 'p1' }, 'create_property_list', {}); + assert.match(result.idempotency_key, UUID_V4); + }); + + test('does not inject when step.omit_idempotency_key=true — the scenario explicitly exercises missing-key rejection', () => { + const result = applyIdempotencyInvariant({ name: 'p1' }, 'create_property_list', { + omit_idempotency_key: true, + }); + assert.strictEqual(result.idempotency_key, undefined); + }); + + test('covers tasks not in TASK_TO_METHOD that fall through to executeTask (e.g. acquire_rights)', () => { + const result = applyIdempotencyInvariant({ campaign: { id: 'c1' } }, 'acquire_rights', {}); + assert.match(result.idempotency_key, UUID_V4); + }); + + test('does not mutate the input request', () => { + const input = { name: 'p1' }; + applyIdempotencyInvariant(input, 'create_property_list', {}); + assert.strictEqual(input.idempotency_key, undefined); + }); +}); + +// ──────────────────────────────────────────────────────────── +// Integration: runner call-site regression +// ──────────────────────────────────────────────────────────── + +/** + * Wire-level: a mutating step with a sample_request that omits + * idempotency_key must still land at the server carrying one. Before the + * fix, the server's required-field check would short-circuit and the + * handler under test would never run. + */ +describe('runStoryboard: idempotency_key invariant on the wire', () => { + it('auto-injects idempotency_key on mutating steps whose sample_request omits it, including untyped tasks (acquire_rights) that fall through to executeTask', async () => { + const seen = []; + const server = http.createServer(async (req, res) => { + const chunks = []; + for await (const c of req) chunks.push(c); + const rpc = JSON.parse(Buffer.concat(chunks).toString('utf8')); + seen.push({ name: rpc.params.name, args: rpc.params.arguments }); + res.writeHead(401, { 'content-type': 'application/json', 'www-authenticate': 'Bearer realm="x"' }); + res.end('{}'); + }); + await new Promise(r => server.listen(0, r)); + const agentUrl = `http://127.0.0.1:${server.address().port}/mcp`; + try { + const storyboard = { + id: 'idempotency_invariant_sb', + version: '1.0.0', + title: 'Idempotency invariant', + category: 'compliance', + summary: '', + narrative: '', + agent: { interaction_model: '*', capabilities: [] }, + caller: { role: 'buyer_agent' }, + phases: [ + { + id: 'p', + title: 'mutating', + steps: [ + { + id: 's1_typed_mutating_sample_omits_key', + title: 'typed mutating task, sample_request omits idempotency_key', + task: 'create_property_list', + auth: 'none', + sample_request: { name: 'pl-1', entries: [] }, + validations: [{ check: 'http_status_in', allowed_values: [401], description: '' }], + }, + { + id: 's2_untyped_acquire_rights', + title: 'untyped mutating task falls through to executeTask', + task: 'acquire_rights', + auth: 'none', + sample_request: { campaign: { id: 'c1' } }, + validations: [{ check: 'http_status_in', allowed_values: [401], description: '' }], + }, + { + id: 's3_read_only_untouched', + title: 'read-only task must not receive idempotency_key', + task: 'get_products', + auth: 'none', + sample_request: { brief: 'test' }, + validations: [{ check: 'http_status_in', allowed_values: [401], description: '' }], + }, + ], + }, + ], + }; + await runStoryboard(agentUrl, storyboard, { + protocol: 'mcp', + allow_http: true, + agentTools: ['create_property_list', 'acquire_rights', 'get_products'], + _profile: { + name: 'Test', + tools: ['create_property_list', 'acquire_rights', 'get_products'], + }, + _client: { + getAgentInfo: async () => ({ + name: 'Test', + tools: [{ name: 'create_property_list' }, { name: 'acquire_rights' }, { name: 'get_products' }], + }), + }, + }); + + assert.strictEqual(seen.length, 3, `expected 3 tool calls, got ${seen.length}`); + + const mutating = seen.filter(c => c.name === 'create_property_list' || c.name === 'acquire_rights'); + for (const call of mutating) { + assert.match( + String(call.args.idempotency_key), + UUID_V4, + `step ${call.name} missing or malformed idempotency_key` + ); + } + + const readOnly = seen.find(c => c.name === 'get_products'); + assert.strictEqual(readOnly.args.idempotency_key, undefined, 'read-only task must not receive idempotency_key'); + } finally { + server.close(); + } + }); + + it('injects on expect_error mutating steps so storyboards testing GOVERNANCE_DENIED / UNAUTHORIZED / brand_mismatch reach the error path they named', async () => { + const seen = []; + const server = http.createServer(async (req, res) => { + const chunks = []; + for await (const c of req) chunks.push(c); + const rpc = JSON.parse(Buffer.concat(chunks).toString('utf8')); + seen.push({ name: rpc.params.name, args: rpc.params.arguments }); + res.writeHead(403, { 'content-type': 'application/json' }); + res.end( + JSON.stringify({ + jsonrpc: '2.0', + id: rpc.id, + error: { code: -32000, message: 'GOVERNANCE_DENIED' }, + }) + ); + }); + await new Promise(r => server.listen(0, r)); + const agentUrl = `http://127.0.0.1:${server.address().port}/mcp`; + try { + const storyboard = { + id: 'expect_governance_denied_sb', + version: '1.0.0', + title: 'Governance denied', + category: 'compliance', + summary: '', + narrative: '', + agent: { interaction_model: '*', capabilities: [] }, + caller: { role: 'buyer_agent' }, + phases: [ + { + id: 'p', + title: 'governance', + steps: [ + { + id: 's1_expect_governance_denied', + title: 'mutating step expects GOVERNANCE_DENIED, must still carry idempotency_key', + task: 'acquire_rights', + auth: 'none', + expect_error: true, + sample_request: { campaign: { id: 'c1' } }, + validations: [], + }, + ], + }, + ], + }; + await runStoryboard(agentUrl, storyboard, { + protocol: 'mcp', + allow_http: true, + agentTools: ['acquire_rights'], + _profile: { name: 'Test', tools: ['acquire_rights'] }, + _client: { + getAgentInfo: async () => ({ name: 'Test', tools: [{ name: 'acquire_rights' }] }), + }, + }); + + assert.strictEqual(seen.length, 1, `expected 1 tool call, got ${seen.length}`); + assert.match( + String(seen[0].args.idempotency_key), + UUID_V4, + 'expect_error step on mutating task must still receive auto-injected idempotency_key' + ); + } finally { + server.close(); + } + }); + + it('leaves idempotency_key absent when step.omit_idempotency_key=true so the server sees a missing-key request', async () => { + const seen = []; + const server = http.createServer(async (req, res) => { + const chunks = []; + for await (const c of req) chunks.push(c); + const rpc = JSON.parse(Buffer.concat(chunks).toString('utf8')); + seen.push({ name: rpc.params.name, args: rpc.params.arguments }); + res.writeHead(400, { 'content-type': 'application/json' }); + res.end( + JSON.stringify({ + jsonrpc: '2.0', + id: rpc.id, + error: { code: -32000, message: 'INVALID_REQUEST: idempotency_key is required' }, + }) + ); + }); + await new Promise(r => server.listen(0, r)); + const agentUrl = `http://127.0.0.1:${server.address().port}/mcp`; + try { + const storyboard = { + id: 'missing_key_sb', + version: '1.0.0', + title: 'Missing key', + category: 'compliance', + summary: '', + narrative: '', + agent: { interaction_model: '*', capabilities: [] }, + caller: { role: 'buyer_agent' }, + phases: [ + { + id: 'p', + title: 'error', + steps: [ + { + id: 's1_expect_missing_key_error', + title: 'compliance step: missing-key must be rejected', + task: 'create_property_list', + auth: 'none', + expect_error: true, + omit_idempotency_key: true, + sample_request: { name: 'pl-1', entries: [] }, + validations: [], + }, + ], + }, + ], + }; + await runStoryboard(agentUrl, storyboard, { + protocol: 'mcp', + allow_http: true, + agentTools: ['create_property_list'], + _profile: { name: 'Test', tools: ['create_property_list'] }, + _client: { + getAgentInfo: async () => ({ name: 'Test', tools: [{ name: 'create_property_list' }] }), + }, + }); + + assert.strictEqual(seen.length, 1, `expected 1 tool call, got ${seen.length}`); + assert.strictEqual( + seen[0].args.idempotency_key, + undefined, + 'omit_idempotency_key step must not receive auto-injected idempotency_key' + ); + } finally { + server.close(); + } + }); +}); diff --git a/test/lib/v3-compatibility.test.js b/test/lib/v3-compatibility.test.js index 41fe16334..7af940db1 100644 --- a/test/lib/v3-compatibility.test.js +++ b/test/lib/v3-compatibility.test.js @@ -15,8 +15,11 @@ const { supportsProtocol, supportsPropertyListFiltering, supportsContentStandards, + supportsExperimentalFeature, requiresOperatorAuth, requiresAccountForProducts, + resolveFeature, + listDeclaredFeatures, MEDIA_BUY_TOOLS, SIGNALS_TOOLS, CREATIVE_TOOLS, @@ -264,6 +267,79 @@ describe('parseCapabilitiesResponse', () => { assert.ok(capabilities.account); assert.deepStrictEqual(capabilities.account.supportedBilling, []); }); + + test('should parse experimental_features from the AdCP 3.0 GA response envelope', () => { + const response = { + adcp: { major_versions: [3] }, + supported_protocols: ['media_buy', 'brand'], + extensions_supported: [], + experimental_features: ['brand.rights_lifecycle', 'governance.campaign'], + }; + + const capabilities = parseCapabilitiesResponse(response); + + assert.deepStrictEqual(capabilities.experimentalFeatures, ['brand.rights_lifecycle', 'governance.campaign']); + }); + + test('experimentalFeatures is undefined when the seller does not declare any', () => { + const response = { + adcp: { major_versions: [3] }, + supported_protocols: ['media_buy'], + extensions_supported: [], + }; + + const capabilities = parseCapabilitiesResponse(response); + + assert.strictEqual(capabilities.experimentalFeatures, undefined); + }); + + test('experimentalFeatures drops non-string entries rather than throwing on malformed payloads', () => { + const response = { + adcp: { major_versions: [3] }, + supported_protocols: ['media_buy'], + extensions_supported: [], + experimental_features: ['brand.rights_lifecycle', 123, null, 'governance.campaign'], + }; + + const capabilities = parseCapabilitiesResponse(response); + + assert.deepStrictEqual(capabilities.experimentalFeatures, ['brand.rights_lifecycle', 'governance.campaign']); + }); +}); + +describe('supportsExperimentalFeature', () => { + const caps = parseCapabilitiesResponse({ + adcp: { major_versions: [3] }, + supported_protocols: ['brand'], + extensions_supported: [], + experimental_features: ['brand.rights_lifecycle'], + }); + + test('returns true when the seller declared the feature id', () => { + assert.strictEqual(supportsExperimentalFeature(caps, 'brand.rights_lifecycle'), true); + }); + + test('returns false when the seller did not declare the feature id', () => { + assert.strictEqual(supportsExperimentalFeature(caps, 'governance.campaign'), false); + }); + + test('returns false when the seller declared no experimental features at all', () => { + const noneCaps = parseCapabilitiesResponse({ + adcp: { major_versions: [3] }, + supported_protocols: ['media_buy'], + extensions_supported: [], + }); + assert.strictEqual(supportsExperimentalFeature(noneCaps, 'brand.rights_lifecycle'), false); + }); + + test('resolveFeature honors the experimental: namespace', () => { + assert.strictEqual(resolveFeature(caps, 'experimental:brand.rights_lifecycle'), true); + assert.strictEqual(resolveFeature(caps, 'experimental:governance.campaign'), false); + }); + + test('listDeclaredFeatures surfaces experimental ids so error messages can cite them', () => { + assert.ok(listDeclaredFeatures(caps).includes('experimental:brand.rights_lifecycle')); + }); }); describe('Capability Checks', () => { @@ -1108,9 +1184,52 @@ describe('V3 Feature Guard Logic', () => { // ============================================ const { normalizeRequestParams, normalizePackageParams } = require('../../dist/lib/utils/request-normalizer.js'); +const { MUTATING_TASKS } = require('../../dist/lib/utils/idempotency.js'); const { resetWarnings } = require('../../dist/lib/utils/deprecation.js'); +const UUID_V4_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +describe('normalizeRequestParams: idempotency_key auto-inject (MUTATING_TASKS coverage)', () => { + test('every MUTATING_TASKS member gets a UUID v4 when the caller omits idempotency_key', () => { + assert.ok(MUTATING_TASKS.size >= 20, `MUTATING_TASKS set looks empty (${MUTATING_TASKS.size})`); + for (const task of MUTATING_TASKS) { + const result = normalizeRequestParams(task, {}); + assert.match( + String(result?.idempotency_key), + UUID_V4_PATTERN, + `${task}: expected auto-injected UUID v4 idempotency_key, got ${JSON.stringify(result?.idempotency_key)}` + ); + } + }); + + test('read-only tasks do not receive an auto-injected idempotency_key', () => { + for (const task of ['get_products', 'get_signals', 'get_media_buys', 'list_accounts', 'list_creatives']) { + const result = normalizeRequestParams(task, {}); + assert.strictEqual( + result?.idempotency_key, + undefined, + `${task}: read-only task should not auto-inject idempotency_key` + ); + } + }); + + test('preserves a caller-supplied idempotency_key (BYOK)', () => { + const result = normalizeRequestParams('create_media_buy', { idempotency_key: 'byok-1234567890abcdef' }); + assert.strictEqual(result?.idempotency_key, 'byok-1234567890abcdef'); + }); + + test('treats empty-string idempotency_key as unset and injects a fresh UUID', () => { + const result = normalizeRequestParams('create_media_buy', { idempotency_key: '' }); + assert.match(String(result?.idempotency_key), UUID_V4_PATTERN); + }); + + test('skipIdempotencyAutoInject=true leaves the field absent so compliance scenarios can exercise missing-key rejection', () => { + const result = normalizeRequestParams('create_media_buy', {}, { skipIdempotencyAutoInject: true }); + assert.strictEqual(result?.idempotency_key, undefined); + }); +}); + describe('Request Parameter Normalization', () => { // Reset deprecation warnings before each test so warnOnce fires // (node:test doesn't have beforeEach, but we can call inline)