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
6 changes: 6 additions & 0 deletions .changeset/framework-webhook-operation-id.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@adcp/sdk': patch
---

Include `operation_id` in framework-emitted task webhook payloads and validate
push notification operation identifiers at the request boundary.
6 changes: 3 additions & 3 deletions package-lock.json

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

58 changes: 51 additions & 7 deletions src/lib/server/decisioning/runtime/from-platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2550,6 +2550,7 @@ interface DispatchHitlOpts {
accountId: string;
pushNotificationUrl?: string;
pushNotificationToken?: string;
pushNotificationOperationId?: string;
emitWebhook?: HandlerContext<Account>['emitWebhook'];
observability?: DecisioningObservabilityHooks;
logger: AdcpLogger;
Expand Down Expand Up @@ -2678,7 +2679,7 @@ async function emitSyncCompletionWebhook(opts: DispatchHitlOpts, result: unknown
const r = await opts.emitWebhook({
url: opts.pushNotificationUrl,
payload: wirePayload,
operation_id: `${opts.tool}.${taskId}`,
operation_id: resolveWebhookDeliveryOperationId(opts, taskId),
});
success = r?.delivered === true;
if (r && Array.isArray(r.errors) && r.errors.length > 0) {
Expand Down Expand Up @@ -2839,8 +2840,8 @@ async function dispatchHitl<TResult>(
* `result`. This helper builds that shape from the v6 task lifecycle data.
*
* Spec requires top-level: `idempotency_key`, `task_id`, `task_type`, `status`,
* `timestamp`. Optional: `protocol`, `context_id`, `message`, `result`,
* `operation_id`. We add `validation_token` (echoed from
* `timestamp`, `operation_id`. Optional: `protocol`, `context_id`, `message`,
* `result`. We add `validation_token` (echoed from
* `push_notification_config.token`) outside the spec but consistent with the
* intent — receivers that don't expect it ignore the extra property.
*
Expand All @@ -2859,6 +2860,7 @@ function buildTaskWebhookPayload(
const idempotencyKey = randomUUID();
const payload: Record<string, unknown> = {
idempotency_key: idempotencyKey,
operation_id: resolveWebhookPayloadOperationId(opts, taskId),
task_id: taskId,
task_type: opts.tool,
status,
Expand Down Expand Up @@ -2887,6 +2889,14 @@ function buildTaskWebhookPayload(
return payload;
}

function resolveWebhookPayloadOperationId(opts: DispatchHitlOpts, taskId: string): string {
return opts.pushNotificationOperationId ?? `${opts.tool}.${taskId}`;
}

function resolveWebhookDeliveryOperationId(opts: DispatchHitlOpts, taskId: string): string {
return `task-webhook:${opts.accountId}:${opts.tool}:${taskId}`;
}

// `protocolForTool` and `SPEC_WEBHOOK_TASK_TYPES` are exported from
// `protocol-for-tool.ts` — see import at top of file.

Expand Down Expand Up @@ -2922,7 +2932,7 @@ async function emitTaskWebhook(
const result = await opts.emitWebhook({
url: opts.pushNotificationUrl,
payload: wirePayload,
operation_id: `${opts.tool}.${taskId}`,
operation_id: resolveWebhookDeliveryOperationId(opts, taskId),
});
success = result?.delivered === true;
if (result && Array.isArray(result.errors) && result.errors.length > 0) {
Expand Down Expand Up @@ -3522,12 +3532,14 @@ function extractPushConfig(
params: unknown,
_logger: AdcpLogger,
opts: { allowPrivateWebhookUrls?: boolean } = {}
): { url?: string; token?: string } {
): { url?: string; token?: string; operationId?: string } {
if (!params || typeof params !== 'object') return {};
const cfg = (params as { push_notification_config?: unknown }).push_notification_config;
if (!cfg || typeof cfg !== 'object') return {};
const rawUrl = (cfg as { url?: unknown }).url;
const rawToken = (cfg as { token?: unknown }).token;
const cfgObj = cfg as { url?: unknown; token?: unknown; operation_id?: unknown };
const rawUrl = cfgObj.url;
const rawToken = cfgObj.token;
const rawOperationId = cfgObj.operation_id;

let url: string | undefined;
if (typeof rawUrl === 'string') {
Expand Down Expand Up @@ -3559,9 +3571,28 @@ function extractPushConfig(
token = rawToken;
}

let operationId: string | undefined;
if (Object.prototype.hasOwnProperty.call(cfgObj, 'operation_id')) {
if (typeof rawOperationId !== 'string') {
throw new AdcpError('INVALID_REQUEST', {
message: 'push_notification_config.operation_id rejected: operation_id must be a string',
field: 'push_notification_config.operation_id',
});
}
const validation = validatePushNotificationOperationId(rawOperationId);
if (!validation.ok) {
throw new AdcpError('INVALID_REQUEST', {
message: `push_notification_config.operation_id rejected: ${validation.reason}`,
field: 'push_notification_config.operation_id',
});
}
operationId = rawOperationId;
}

return {
...(url !== undefined && { url }),
...(token !== undefined && { token }),
...(operationId !== undefined && { operationId }),
};
}

Expand Down Expand Up @@ -3684,6 +3715,7 @@ function validatePushNotificationUrl(rawUrl: string, opts: { allowPrivate?: bool

const TOKEN_MAX_LENGTH = 255;
const TOKEN_CONTROL_CHAR_RE = /[\x00-\x1f\x7f]/;
const PUSH_OPERATION_ID_RE = /^[A-Za-z0-9_.:-]{1,255}$/;

function validatePushNotificationToken(token: string): UrlValidationResult {
if (token.length === 0) {
Expand All @@ -3698,6 +3730,13 @@ function validatePushNotificationToken(token: string): UrlValidationResult {
return { ok: true };
}

function validatePushNotificationOperationId(operationId: string): UrlValidationResult {
if (!PUSH_OPERATION_ID_RE.test(operationId)) {
return { ok: false, reason: `operation_id must match ${PUSH_OPERATION_ID_RE.source}` };
}
return { ok: true };
}

function buildMediaBuyHandlers<P extends DecisioningPlatform<any, any>>(
platform: P,
taskRegistry: TaskRegistry,
Expand Down Expand Up @@ -3771,6 +3810,7 @@ function buildMediaBuyHandlers<P extends DecisioningPlatform<any, any>>(
accountId: reqCtx.account.id,
pushNotificationUrl: push.url,
pushNotificationToken: push.token,
pushNotificationOperationId: push.operationId,
emitWebhook: taskWebhookEmit ?? ctx.emitWebhook,
autoEmitCompletion: pushOpts.autoEmitCompletionWebhooks,
observability,
Expand Down Expand Up @@ -3906,6 +3946,7 @@ function buildMediaBuyHandlers<P extends DecisioningPlatform<any, any>>(
accountId: reqCtx.account.id,
pushNotificationUrl: push.url,
pushNotificationToken: push.token,
pushNotificationOperationId: push.operationId,
emitWebhook: taskWebhookEmit ?? ctx.emitWebhook,
autoEmitCompletion: pushOpts.autoEmitCompletionWebhooks,
observability,
Expand Down Expand Up @@ -3984,6 +4025,7 @@ function buildMediaBuyHandlers<P extends DecisioningPlatform<any, any>>(
accountId: reqCtx.account.id,
pushNotificationUrl: push.url,
...(push.token !== undefined && { pushNotificationToken: push.token }),
...(push.operationId !== undefined && { pushNotificationOperationId: push.operationId }),
emitWebhook: taskWebhookEmit ?? ctx.emitWebhook,
...(observability && { observability }),
logger,
Expand Down Expand Up @@ -4019,6 +4061,7 @@ function buildMediaBuyHandlers<P extends DecisioningPlatform<any, any>>(
accountId: reqCtx.account.id,
pushNotificationUrl: push.url,
pushNotificationToken: push.token,
pushNotificationOperationId: push.operationId,
emitWebhook: taskWebhookEmit ?? ctx.emitWebhook,
autoEmitCompletion: pushOpts.autoEmitCompletionWebhooks,
observability,
Expand Down Expand Up @@ -4270,6 +4313,7 @@ function buildCreativeHandlers<P extends DecisioningPlatform<any, any>>(
accountId: reqCtx.account.id,
pushNotificationUrl: push.url,
pushNotificationToken: push.token,
pushNotificationOperationId: push.operationId,
emitWebhook: taskWebhookEmit ?? ctx.emitWebhook,
autoEmitCompletion: pushOpts.autoEmitCompletionWebhooks,
observability,
Expand Down
103 changes: 97 additions & 6 deletions test/server-decisioning-from-platform.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@ const { AccountNotFoundError } = require('../dist/lib/server/decisioning/account
const { AdcpError } = require('../dist/lib/server/decisioning/async-outcome');
const { setStatusChangeBus, createInMemoryStatusChangeBus } = require('../dist/lib/server/decisioning/status-changes');
const { StaticJwksResolver, InMemoryReplayStore, InMemoryRevocationStore } = require('../dist/lib/signing/server.js');
const { getSchemaValidatorByRef } = require('../dist/lib/validation/schema-loader');

const validateMcpWebhookPayload = getSchemaValidatorByRef('core/mcp-webhook-payload.json');
assert.ok(validateMcpWebhookPayload, 'MCP webhook payload schema must compile');

function assertMcpWebhookPayloadValid(payload) {
assert.strictEqual(validateMcpWebhookPayload(payload), true, JSON.stringify(validateMcpWebhookPayload.errors));
}

function buildPlatform(overrides = {}) {
return {
Expand Down Expand Up @@ -2671,7 +2679,7 @@ describe('HITL push notification webhook on terminal state', () => {
};
}

it('emits webhook on completed task with push_notification_config', async () => {
it('emits webhook on completed task with push_notification_config operation_id', async () => {
const emits = [];
const fakeEmitter = {
emit: async params => {
Expand Down Expand Up @@ -2699,7 +2707,11 @@ describe('HITL push notification webhook on terminal state', () => {
start_time: '2026-05-01T00:00:00Z',
end_time: '2026-06-01T00:00:00Z',
account: { account_id: 'acc_1' },
push_notification_config: { url: 'https://buyer.example.com/webhook', token: 'shhh' },
push_notification_config: {
url: 'https://buyer.example.com/webhook',
token: 'webhook-token-1234',
operation_id: 'op_webhook_test',
},
},
},
});
Expand All @@ -2716,13 +2728,63 @@ describe('HITL push notification webhook on terminal state', () => {
// carries the success-arm body.
assert.strictEqual(emit.payload.task_id, taskId);
assert.strictEqual(emit.payload.task_type, 'create_media_buy');
assert.strictEqual(emit.payload.operation_id, 'op_webhook_test');
assert.strictEqual(emit.payload.status, 'completed');
assert.strictEqual(emit.payload.protocol, 'media-buy');
assert.ok(typeof emit.payload.idempotency_key === 'string' && emit.payload.idempotency_key.length >= 16);
assert.ok(typeof emit.payload.timestamp === 'string');
assert.deepStrictEqual(emit.payload.result, { media_buy_id: 'mb_42', status: 'active' });
assert.strictEqual(emit.payload.token, 'shhh');
assert.ok(emit.operation_id.startsWith('create_media_buy.task_'));
assert.strictEqual(emit.payload.token, 'webhook-token-1234');
assert.match(emit.operation_id, new RegExp(`^task-webhook:acc_1:create_media_buy:${taskId}$`));
assert.notStrictEqual(emit.operation_id, 'op_webhook_test');
assertMcpWebhookPayloadValid(emit.payload);
});

it('treats webhook URL as opaque when push config omits operation_id', async () => {
const emits = [];
const fakeEmitter = {
emit: async params => {
emits.push(params);
return { operation_id: params.operation_id, idempotency_key: 'k', attempts: 1, delivered: true, errors: [] };
},
};

const platform = buildHitlPlatform(async () => ({ media_buy_id: 'mb_42', status: 'active' }));
const server = createAdcpServerFromPlatform(platform, {
name: 'webhook',
version: '0.0.1',
validation: { requests: 'off', responses: 'off' },
taskWebhookEmitter: fakeEmitter,
});

const result = await server.dispatchTestRequest({
method: 'tools/call',
params: {
name: 'create_media_buy',
arguments: {
buyer_ref: 'b1',
idempotency_key: '11111111-1111-1111-1111-111111111111',
packages: [],
start_time: '2026-05-01T00:00:00Z',
end_time: '2026-06-01T00:00:00Z',
account: { account_id: 'acc_1' },
push_notification_config: {
url: 'https://buyer.example.com/step/create_media_buy/op_url_must_not_be_parsed',
token: 'webhook-token-1234',
},
},
},
});

assert.strictEqual(result.structuredContent.status, 'submitted');
await server.awaitTask(result.structuredContent.task_id);

assert.strictEqual(emits.length, 1, 'one webhook emitted on terminal completion');
assert.ok(emits[0].payload.operation_id.startsWith('create_media_buy.'));
assert.match(emits[0].operation_id, /^task-webhook:acc_1:create_media_buy:task_/);
assert.notStrictEqual(emits[0].payload.operation_id, 'op_url_must_not_be_parsed');
assert.notStrictEqual(emits[0].operation_id, 'op_url_must_not_be_parsed');
assertMcpWebhookPayloadValid(emits[0].payload);
});

it('emits webhook on failed task with structured error', async () => {
Expand Down Expand Up @@ -2835,7 +2897,7 @@ describe('Push notification webhook URL/token validation (B5/B6)', () => {
};
}

async function dispatchWithUrl(server, url, token) {
async function dispatchWithPushConfig(server, pushNotificationConfig) {
return server.dispatchTestRequest({
method: 'tools/call',
params: {
Expand All @@ -2847,12 +2909,16 @@ describe('Push notification webhook URL/token validation (B5/B6)', () => {
start_time: '2026-05-01T00:00:00Z',
end_time: '2026-06-01T00:00:00Z',
account: { account_id: 'acc_1' },
push_notification_config: { url, ...(token != null && { token }) },
push_notification_config: pushNotificationConfig,
},
},
});
}

async function dispatchWithUrl(server, url, token) {
return dispatchWithPushConfig(server, { url, ...(token != null && { token }) });
}

function makeServer({ warns, emits } = {}) {
return createAdcpServerFromPlatform(
buildHitlPlatform(async () => ({ media_buy_id: 'mb_1' })),
Expand Down Expand Up @@ -2972,6 +3038,31 @@ describe('Push notification webhook URL/token validation (B5/B6)', () => {
assert.strictEqual(emits.length, 0);
});

for (const [label, operationId, reasonFragment] of [
['non-string operation_id', 123, 'must be a string'],
['empty operation_id', '', 'must match'],
['operation_id over 255 chars', 'a'.repeat(256), 'must match'],
['operation_id with invalid character', 'op/bad', 'must match'],
['operation_id with control character', 'op_\n_bad', 'must match'],
]) {
it(`rejects ${label}`, async () => {
const emits = [];
const server = makeServer({ emits });
const result = await dispatchWithPushConfig(server, {
url: 'https://buyer.example.com/webhook',
operation_id: operationId,
});
assert.strictEqual(result.isError, true);
assert.strictEqual(result.structuredContent.adcp_error.code, 'INVALID_REQUEST');
assert.strictEqual(result.structuredContent.adcp_error.field, 'push_notification_config.operation_id');
assert.ok(
result.structuredContent.adcp_error.message.includes(reasonFragment),
`expected rejection reason "${reasonFragment}", got: ${result.structuredContent.adcp_error.message}`
);
assert.strictEqual(emits.length, 0);
});
}

it('accepts a well-formed token', async () => {
const emits = [];
const server = makeServer({ emits });
Expand Down
Loading