diff --git a/.changeset/account-requests-seller.md b/.changeset/account-requests-seller.md new file mode 100644 index 000000000..c54e0eff4 --- /dev/null +++ b/.changeset/account-requests-seller.md @@ -0,0 +1,7 @@ +--- +'@adcp/sdk': minor +--- + +Sellers registering `request_account` (`accounts.requestAccount`) now advertise AdCP 3.3 `account.requests` on `get_adcp_capabilities`, with `request_task`, `read_task: 'list_accounts'` and the `account.requests` experimental feature id, so `client.accounts.ensure` routes to them. `capabilities.accountRequests` supplies `changeableAttributes`, `pendingActionKinds` and `eventType`. On the platform path, the new `accounts.listRequests(request, ctx)` hook serves the caller's requests as `account_requests[]` and `account_requests_pagination` on `list_accounts` when the buyer sends `include_requests`, `request_ids` or `requests_pagination`. `Account.revision` is now served on `list_accounts`, so buyers can read the revision a change is based on. Fixes #3575. + +**Behavior change for 3.3 beta adopters:** a seller that registers `request_account` must declare who bills, because `account.requests` lives in the account capability block, whose schema requires `supported_billing`. A server that registers it without one is now refused at construction. Fix: add `capabilities: { account: { supportedBilling: ['agent'] } }` to `createAdcpServer`, or `capabilities.supportedBillings` on the platform path, with your actual billing model. The SDK never fills in this claim for you. diff --git a/docs/guides/account-resolution.md b/docs/guides/account-resolution.md index b8982ba7a..e80b7d473 100644 --- a/docs/guides/account-resolution.md +++ b/docs/guides/account-resolution.md @@ -141,6 +141,68 @@ resolve: async (ref, ctx) => { When `resolveAccount` is configured, a supplied unknown reference returns `ACCOUNT_NOT_FOUND`, even on optional account tools. +### Account requests (`request_account`, AdCP 3.3 experimental) + +A seller that serves `request_account` registers it through the handler merge +seam, and serves the caller's requests back on `list_accounts` through +`accounts.listRequests`: + +```ts +createAdcpServerFromPlatform( + { + capabilities: { + // ... + supportedBillings: ['agent'], // required with account requests: who bills + accountRequests: { + changeableAttributes: ['billing', 'payment_terms', 'timezone'], + pendingActionKinds: ['review', 'accept_terms'], + }, + }, + accounts: { + resolution: 'explicit', + resolve, + list, + // Only when the buyer sends include_requests, request_ids or requests_pagination. + listRequests: async (request, ctx) => { + const page = await db.requestsFor(callerOf(ctx), { + ids: request.request_ids, + cursor: request.requests_pagination?.cursor, + }); + return { items: page.records, nextCursor: page.next }; + }, + }, + }, + { accounts: { requestAccount: async (params, ctx) => ({ request: await db.request(params, ctx) }) } } +); +``` + +Registering `requestAccount` advertises `account.requests` on +`get_adcp_capabilities` (`supported: true`, `request_task: 'request_account'`, +`read_task: 'list_accounts'`, plus `changeable_attributes`, +`pending_action_kinds` and `event_type` from `capabilities.accountRequests`) and +adds `account.requests` to `experimental_features`. Callers on releases before +3.3 see neither. Buyers route `client.accounts.ensure` and +`client.accounts.change` by this block. The same applies to `createAdcpServer`, +with `capabilities.accountRequests`. + +The block lives in the account capability block, whose schema requires +`supported_billing`, so declare who bills: `capabilities.supportedBillings` here, +or `capabilities.account.supportedBilling` on `createAdcpServer`. A server that +registers `requestAccount` without one is refused at construction; the SDK never +fills in a billing claim for you. + +`listRequests` returns a `CursorPage` of wire account-request records, which the +framework serves as `account_requests[]` and `account_requests_pagination`; the +accounts page is unaffected. Return only the caller's own requests, newest +`updated_at` first and at most 100 per page, and omit unknown or foreign +`request_ids` silently. It needs `accounts.list`. Without it, the framework warns +at startup that buyers cannot poll a pending request. + +Return `revision` on the accounts `accounts.list` lists. Buyers read it from +`list_accounts` and send it with a change, and a money change (`billing`, +`billing_entity`, `payment_terms`) without it should be refused with +`INVALID_REQUEST`; a stale one with `CONFLICT`. + ### Strict account references (`strictAccountReferences`) SDK 14 keeps four compatibility behaviors that strict mode removes: diff --git a/src/lib/server/create-adcp-server.ts b/src/lib/server/create-adcp-server.ts index 9144c848d..29c78344e 100644 --- a/src/lib/server/create-adcp-server.ts +++ b/src/lib/server/create-adcp-server.ts @@ -432,6 +432,7 @@ import type { GetMediaBuyIssueResponse, RequestAccountResponse, GetAdCPCapabilitiesResponse, + AccountRequestsSupported, CreatePropertyListResponse, UpdatePropertyListResponse, GetPropertyListResponse, @@ -1418,6 +1419,33 @@ export interface AdcpCapabilitiesConfig { * them via their dedicated fields on {@link AdcpCapabilitiesConfig}. */ overrides?: AdcpCapabilitiesOverrides; + /** + * Seller-supplied fields of the `account.requests` block (AdCP 3.3, experimental). The block itself + * is advertised automatically when `accounts.requestAccount` is registered: `supported: true`, + * `request_task: 'request_account'`, `read_task: 'list_accounts'`, and `account.requests` in + * `experimental_features`. Requires `accounts.requestAccount`. + */ + accountRequests?: AccountRequestsCapabilityOptions; +} + +/** + * What a seller registering `request_account` adds to its `account.requests` capability block. Both + * lists are advisory to buyers; the seller still validates each request. + */ +export interface AccountRequestsCapabilityOptions { + /** + * Account attributes the seller accepts in a change request (`account_id` plus fields). Absent or + * empty means the seller only opens accounts. `currency` and `timezone` are changeable only when + * listed. + */ + changeableAttributes?: readonly NonNullable[number][]; + /** Pending-action kinds the seller may issue, so buyers can prepare the people who will act. */ + pendingActionKinds?: readonly NonNullable[number][]; + /** + * Set when the seller fires `account_request.changed`. A seller declaring it also declares + * `capability_changes.notifications.supported: true`; without it, buyers poll `list_accounts`. + */ + eventType?: 'account_request.changed'; } /** Inclusive AdCP release range in which a registered tool is available. */ @@ -3947,7 +3975,12 @@ function filterCapabilitiesForToolAvailability( const experimentalTools = [ ['list_collections', ['media_buy.collection_catalog', 'media_buy.collection_cards']], ['list_proposals', ['media_buy.open_opportunities']], + ['request_account', ['account.requests']], ] as const; + if (data.account?.requests && !(registeredTools.has('request_account') && isAvailable('request_account', release))) { + // `account.requests` names request_account; a release without the tool has no such block. + delete data.account.requests; + } const features = new Set(data.experimental_features ?? []); for (const [tool, requiredFeatures] of experimentalTools) { for (const feature of requiredFeatures) { @@ -9599,6 +9632,39 @@ export function createAdcpServer(config: AdcpServerConfig | undefined; + const options = capConfig?.accountRequests; + capabilitiesData.account.requests = { + ...(adopterRequests?.supported === true ? adopterRequests : {}), + supported: true, + request_task: 'request_account', + read_task: 'list_accounts', + ...(options?.changeableAttributes?.length && { changeable_attributes: [...options.changeableAttributes] }), + ...(options?.pendingActionKinds?.length && { pending_action_kinds: [...options.pendingActionKinds] }), + ...(options?.eventType && { event_type: options.eventType }), + }; + } else if (capConfig?.accountRequests) { + throw new Error( + 'createAdcpServer: capabilities.accountRequests describes request_account; register accounts.requestAccount too.' + ); + } + if (webhooks) { const profile = webhooks.tag ?? 'adcp/webhook-signing/v1'; if (profile !== 'adcp/webhook-signing/v1') { diff --git a/src/lib/server/decisioning/account.ts b/src/lib/server/decisioning/account.ts index 2e7f687d2..2e2a4752d 100644 --- a/src/lib/server/decisioning/account.ts +++ b/src/lib/server/decisioning/account.ts @@ -44,6 +44,7 @@ import type { GetAccountFinancialsResponse, GetAccountFinancialsSuccess, } from '../../types/tools.generated'; +import type { AccountRequest } from '../../types/tools.generated'; import type { ServerPayload } from '../../types/server-payload'; import { projectReportingDeliveryConfigStates } from '../reporting-delivery-config'; import { @@ -92,6 +93,8 @@ export type ReportUsagePayload = ServerPayload; export type GetAccountFinancialsPayload = ServerPayload; export type GetAccountFinancialsSuccessPayload = ServerPayload; export type ListAccountsHandlerResult> = CursorPage>; +/** A page of the caller's account requests: `account_requests[]` on `list_accounts` (AdCP 3.3). */ +export type ListAccountRequestsHandlerResult = CursorPage; export type ListAccountChangesHandlerResult = ListAccountChangesPayload; export type SyncAccountsHandlerResult = SyncAccountsResultRow[]; export type SyncGovernanceHandlerResult = SyncGovernanceRow[]; @@ -136,6 +139,13 @@ export interface Account> { */ timezone?: string; + /** + * The account's optimistic-concurrency revision (AdCP 3.2+). Maps to wire `Account.revision`. + * Buyers read it from `list_accounts` and send it with a `request_account` change or a + * `sync_accounts` settings update, so a concurrent change is never overwritten. + */ + revision?: number; + /** * The advertiser whose rates apply to this account. Maps to wire * `Account.advertiser`. Use when the account is operated by an agency on @@ -869,6 +879,23 @@ export interface AccountStore> { */ list?(request: ListAccountsRequest, ctx?: ResolveContext): Promise>; + /** + * The caller's account requests on `list_accounts` (AdCP 3.3 experimental `account.requests`). + * Called only when the buyer asks for them, with `include_requests`, `request_ids` or + * `requests_pagination`; the framework returns `items` as `account_requests[]` and projects + * `account_requests_pagination` from `nextCursor` / `totalCount`. The accounts page is unaffected. + * + * Implementations MUST return only the caller's own requests (identify the caller from + * `ctx.authInfo` / `ctx.agent`, as for `list`): every open request (`in_review`, `ambiguous`) plus + * requests that changed state in the last 30 days, newest `updated_at` first, at most 100 per page. + * With `request.request_ids`, return only those, omitting unknown IDs and other callers' IDs + * silently. Page from `request.requests_pagination.cursor`. + * + * This is how buyers poll a pending request (`client.accounts.refresh()`). Pair it with + * `opts.accounts.requestAccount`; requires {@link list}. + */ + listRequests?(request: ListAccountsRequest, ctx?: ResolveContext): Promise; + /** * list_account_changes API surface. Returns a durable feed page whose * cursor remains bound to the authenticated principal, account, and @@ -1171,6 +1198,7 @@ export function toWireAccount(account: Account): WireAccount if (account.brand !== undefined) wire.brand = account.brand; if (account.operator !== undefined) wire.operator = account.operator; if (account.timezone !== undefined) wire.timezone = account.timezone; + if (account.revision !== undefined) wire.revision = account.revision; if (account.advertiser !== undefined) wire.advertiser = account.advertiser; if (account.billing_proxy !== undefined) wire.billing_proxy = account.billing_proxy; if (account.billing !== undefined) { diff --git a/src/lib/server/decisioning/capabilities.ts b/src/lib/server/decisioning/capabilities.ts index 5ee561009..2db6d9202 100644 --- a/src/lib/server/decisioning/capabilities.ts +++ b/src/lib/server/decisioning/capabilities.ts @@ -23,7 +23,7 @@ import type { } from '../../types/tools.generated'; import type { ProductMetricOptimizationLike } from '../../utils/capability-rollups'; import type { MediaBuyFeatures, AccountCapabilities, CreativeCapabilities } from '../../utils/capabilities'; -import type { AdcpCapabilitiesOverrides } from '../create-adcp-server'; +import type { AccountRequestsCapabilityOptions, AdcpCapabilitiesOverrides } from '../create-adcp-server'; /** * Pre-resolved alias for the wire `media_buy` block. Used as the projection @@ -327,6 +327,14 @@ export interface DecisioningCapabilities { */ overrides?: AdcpCapabilitiesOverrides; + /** + * Seller-supplied fields of the `account.requests` capability block (AdCP 3.3, experimental): + * `changeableAttributes`, `pendingActionKinds` and `eventType`. The block itself, with + * `request_task` / `read_task` and the `account.requests` feature id, is advertised automatically + * when `opts.accounts.requestAccount` is registered. Requires `requestAccount`. + */ + accountRequests?: AccountRequestsCapabilityOptions; + /** * Release-precision AdCP versions this platform supports (e.g. `["3.0", "3.1"]`). * Forwarded into `get_adcp_capabilities.adcp.supported_versions`. 3.1+ diff --git a/src/lib/server/decisioning/runtime/from-platform.ts b/src/lib/server/decisioning/runtime/from-platform.ts index e14134b5a..b905ab758 100644 --- a/src/lib/server/decisioning/runtime/from-platform.ts +++ b/src/lib/server/decisioning/runtime/from-platform.ts @@ -2855,6 +2855,25 @@ export function createAdcpServerFromPlatform

>( return projectSync( async () => { const page = await accounts.list!(filter, resolveCtx); + // AdCP 3.3 account.requests: the caller's requests ride on list_accounts when asked for, and + // only on releases that define them (capabilities hide account.requests below 3.3 too). + const served = ctx.servedAdcpVersion; + const requests = + accounts.listRequests && + isAdcpVersionAtLeast(served, '3.3.0-beta.0') && + (filter.include_requests === true || + filter.request_ids !== undefined || + filter.requests_pagination !== undefined) + ? await accounts.listRequests(filter, resolveCtx) + : undefined; + const listed = await projectAccountList(accounts, page.items, resolveCtx, logger); + // Account.revision is an AdCP 3.2 field: not served to an earlier release. + if (served !== undefined && !isAdcpVersionAtLeast(served, '3.2.0-beta.0')) { + for (const row of listed) delete row.revision; + } const response = { status: 'completed' as const, - accounts: await projectAccountList(accounts, page.items, resolveCtx, logger), + accounts: listed, pagination: { has_more: page.nextCursor != null, ...(page.nextCursor != null && { cursor: page.nextCursor }), ...(page.totalCount !== undefined && { total_count: page.totalCount }), }, + ...(requests && { + account_requests: requests.items, + account_requests_pagination: { + has_more: requests.nextCursor != null, + ...(requests.nextCursor != null && { cursor: requests.nextCursor }), + ...(requests.totalCount !== undefined && { total_count: requests.totalCount }), + }, + }), }; return platform.reporting?.projectListAccounts ? platform.reporting.projectListAccounts(filter, response, resolveCtx) diff --git a/test/lib/account-requests-seller.test.js b/test/lib/account-requests-seller.test.js new file mode 100644 index 000000000..ba98db004 --- /dev/null +++ b/test/lib/account-requests-seller.test.js @@ -0,0 +1,454 @@ +/** + * Seller side of AdCP 3.3 `account.requests` (adcp-client#3575): registering `request_account` + * advertises the capability block and feature id, and `accounts.listRequests` serves the caller's + * requests on `list_accounts`. A TS buyer's `client.accounts.ensure` routes through this server. + */ +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const { Client } = require('@modelcontextprotocol/sdk/client/index.js'); +const { InMemoryTransport } = require('@modelcontextprotocol/sdk/inMemory.js'); +const { AgentClient, ADCP_VERSION } = require('../../dist/lib/index.js'); +const { toReleasePrecisionVersion } = require('../../dist/lib/version.js'); +const { createAdcpServer } = require('../../dist/lib/server/create-adcp-server.js'); +const { createAdcpServerFromPlatform } = require('../../dist/lib/server/decisioning/runtime/from-platform.js'); +const { PlatformConfigError } = require('../../dist/lib/server/decisioning/index.js'); +const { createIdempotencyStore, memoryBackend } = require('../../dist/lib/server/idempotency'); +const { getSchemaValidatorByRef } = require('../../dist/lib/validation/schema-loader.js'); + +const WIRE = toReleasePrecisionVersion(ADCP_VERSION); +const NOW = '2026-11-01T00:00:00Z'; +const silent = { debug() {}, info() {}, warn() {}, error() {} }; + +/** + * An in-memory seller: one account the caller can reach, and request records per caller. Opens for + * `review.example` go in review; everything else resolves by creating an account. + */ +function sellerWorld() { + const world = { + accounts: new Map([ + ['acct_1', { account_id: 'acct_1', name: 'Acme', status: 'active', revision: 3, timezone: 'UTC' }], + ]), + requests: new Map(), + listRequestCalls: [], + seq: 0, + }; + const owner = ctx => ctx?.authInfo?.clientId ?? 'anonymous'; + world.record = (ctx, requested, fields) => { + const record = { + request_id: `areq_${++world.seq}`, + requested, + created_at: NOW, + updated_at: NOW, + ...fields, + }; + world.requests.set(record.request_id, { owner: owner(ctx), record }); + return record; + }; + world.requestAccount = async (params, ctx) => { + const { + idempotency_key: _k, + adcp_version: _v, + adcp_major_version: _m, + context: _c, + ext: _e, + ...requested + } = params; + if (requested.brand?.domain === 'review.example') { + return { + request: world.record(ctx, requested, { + state: 'in_review', + pending_action: { waiting_on: 'seller', kind: 'review', message: 'Credit check' }, + }), + }; + } + const accountId = `acct_${requested.brand.domain.split('.')[0]}`; + const account = { account_id: accountId, name: requested.brand.domain, status: 'active', revision: 1 }; + world.accounts.set(accountId, account); + return { + request: world.record(ctx, requested, { + state: 'resolved', + resolution: 'created', + account_id: accountId, + account, + }), + }; + }; + world.approve = requestId => { + const entry = world.requests.get(requestId); + const accountId = 'acct_review'; + const account = { account_id: accountId, name: 'Review', status: 'active', revision: 1 }; + world.accounts.set(accountId, account); + const { pending_action: _p, ...rest } = entry.record; + entry.record = { + ...rest, + state: 'resolved', + resolution: 'created', + account_id: accountId, + account, + updated_at: '2026-11-02T00:00:00Z', + }; + }; + world.store = { + resolution: 'explicit', + resolve: async ref => { + const account = ref?.account_id ? world.accounts.get(ref.account_id) : undefined; + return account ? { id: account.account_id, name: account.name, status: account.status, ctx_metadata: {} } : null; + }, + list: async () => ({ + items: [...world.accounts.values()].map(account => ({ ...account, id: account.account_id, ctx_metadata: {} })), + }), + listRequests: async (filter, ctx) => { + world.listRequestCalls.push(structuredClone(filter)); + const wanted = filter.request_ids ? new Set(filter.request_ids) : undefined; + const items = [...world.requests.values()] + .filter(entry => entry.owner === owner(ctx) && (!wanted || wanted.has(entry.record.request_id))) + .map(entry => structuredClone(entry.record)) + .reverse(); + const start = Number(filter.requests_pagination?.cursor ?? 0); + const limit = filter.requests_pagination?.max_results ?? 100; + const page = items.slice(start, start + limit); + return { + items: page, + ...(start + limit < items.length && { nextCursor: String(start + limit) }), + totalCount: items.length, + }; + }, + }; + return world; +} + +function platformServer(world, extra = {}) { + return createAdcpServerFromPlatform( + { + capabilities: { + specialisms: ['sales-non-guaranteed'], + creative_agents: [], + channels: ['display'], + pricingModels: ['cpm'], + supportedBillings: ['operator'], + config: {}, + accountRequests: { + changeableAttributes: ['billing', 'payment_terms', 'timezone'], + pendingActionKinds: ['review', 'accept_terms'], + }, + ...extra.capabilities, + }, + sales: { getProducts: async () => ({ products: [] }) }, + accounts: { ...world.store, ...extra.accounts }, + }, + { + name: 'account-requests-seller', + version: '1.0.0', + adcpVersion: ADCP_VERSION, + logger: silent, + idempotency: createIdempotencyStore({ backend: memoryBackend({ sweepIntervalMs: 0 }), ttlSeconds: 86400 }), + validation: { requests: 'strict', responses: 'strict' }, + accounts: { requestAccount: world.requestAccount }, + ...extra.options, + } + ); +} + +const AUTH = { authInfo: { token: 'buyer-token', clientId: 'buyer-1', scopes: [] } }; + +async function call(server, name, args, auth = AUTH) { + const response = await server.dispatchTestRequest( + { method: 'tools/call', params: { name, arguments: { adcp_version: WIRE, ...args } } }, + auth + ); + assert.equal(response.isError, undefined, JSON.stringify(response)); + return response.structuredContent; +} + +async function capabilities(server, version = WIRE) { + return call(server, 'get_adcp_capabilities', { adcp_version: version }); +} + +describe('account.requests on the platform path', () => { + test('registering requestAccount advertises the block and the experimental feature id', async t => { + const server = platformServer(sellerWorld()); + t.after(() => server.close()); + const caps = await capabilities(server); + assert.deepEqual(caps.account.requests, { + supported: true, + request_task: 'request_account', + read_task: 'list_accounts', + changeable_attributes: ['billing', 'payment_terms', 'timezone'], + pending_action_kinds: ['review', 'accept_terms'], + }); + assert.ok(caps.experimental_features.includes('account.requests')); + assert.deepEqual(caps.account.supported_billing, ['operator'], 'the platform account block is kept'); + const validate = getSchemaValidatorByRef('protocol/get-adcp-capabilities-response.json'); + assert.equal(validate(caps), true, JSON.stringify(validate.errors)); + }); + + test('a release without request_account sees neither the block nor the feature id', async t => { + const server = platformServer(sellerWorld(), { + capabilities: { supported_versions: ['3.2', WIRE] }, + }); + t.after(() => server.close()); + const caps = await capabilities(server, '3.2'); + assert.equal(caps.account.requests, undefined); + assert.ok(!(caps.experimental_features ?? []).includes('account.requests')); + }); + + test('a platform without requestAccount advertises nothing, even through overrides', async t => { + const world = sellerWorld(); + const server = createAdcpServerFromPlatform( + { + capabilities: { + specialisms: ['sales-non-guaranteed'], + creative_agents: [], + channels: ['display'], + pricingModels: ['cpm'], + supportedBillings: ['operator'], + config: {}, + overrides: { experimental_features: ['account.requests'] }, + }, + sales: {}, + accounts: { resolve: world.store.resolve, list: world.store.list }, + }, + { name: 'no-requests', version: '1.0.0', adcpVersion: ADCP_VERSION, logger: silent } + ); + t.after(() => server.close()); + const caps = await capabilities(server); + assert.equal(caps.account.requests, undefined); + assert.ok(!(caps.experimental_features ?? []).includes('account.requests')); + }); + + test('list_accounts returns the caller’s requests only when asked, schema-valid under strict validation', async t => { + const world = sellerWorld(); + const server = platformServer(world); + t.after(() => server.close()); + const opened = await call(server, 'request_account', { + idempotency_key: 'open-review-0000000001', + brand: { domain: 'review.example' }, + operator: 'agency.example', + }); + assert.equal(opened.request.state, 'in_review'); + await call(server, 'request_account', { + idempotency_key: 'open-ready-00000000001', + brand: { domain: 'ready.example' }, + operator: 'agency.example', + }); + // Another caller's request is never listed. + await call( + server, + 'request_account', + { idempotency_key: 'other-caller-000000001', brand: { domain: 'other.example' }, operator: 'agency.example' }, + { authInfo: { token: 'other', clientId: 'buyer-2', scopes: [] } } + ); + + const plain = await call(server, 'list_accounts', {}); + // Buyers read the revision a change is based on from list_accounts. + assert.equal(plain.accounts.find(account => account.account_id === 'acct_1').revision, 3); + assert.equal(plain.account_requests, undefined); + assert.equal(plain.account_requests_pagination, undefined); + assert.equal(world.listRequestCalls.length, 0, 'listRequests runs only when requests are asked for'); + + const validate = getSchemaValidatorByRef('account/list-accounts-response.json'); + const all = await call(server, 'list_accounts', { include_requests: true }); + assert.deepEqual( + all.account_requests.map(item => [item.request_id, item.state]), + [ + ['areq_2', 'resolved'], + ['areq_1', 'in_review'], + ] + ); + assert.deepEqual(all.account_requests_pagination, { has_more: false, total_count: 2 }); + assert.ok(all.accounts.length >= 1, 'the accounts page is unchanged'); + assert.equal(validate(all), true, JSON.stringify(validate.errors)); + + const one = await call(server, 'list_accounts', { request_ids: ['areq_1', 'areq_3', 'unknown'] }); + assert.deepEqual( + one.account_requests.map(item => item.request_id), + ['areq_1'], + 'unknown IDs and other callers’ IDs are omitted' + ); + + const paged = await call(server, 'list_accounts', { requests_pagination: { max_results: 1 } }); + assert.deepEqual(paged.account_requests_pagination, { has_more: true, cursor: '1', total_count: 2 }); + assert.equal(validate(paged), true, JSON.stringify(validate.errors)); + }); + + test('releases before 3.3 get no account_requests, and before 3.2 no revision', async t => { + const world = sellerWorld(); + const server = platformServer(world, { + capabilities: { supported_versions: ['3.1', '3.2', WIRE] }, + // Production defaults to validation off, so the gate must not rely on request validation. + options: { validation: { requests: 'off', responses: 'off' } }, + }); + t.after(() => server.close()); + await call(server, 'request_account', { + idempotency_key: 'open-review-0000000001', + brand: { domain: 'review.example' }, + operator: 'agency.example', + }); + for (const version of ['3.1', '3.2']) { + const listed = await call(server, 'list_accounts', { + adcp_version: version, + include_requests: true, + request_ids: ['areq_1'], + requests_pagination: { max_results: 10 }, + }); + assert.equal(listed.account_requests, undefined, version); + assert.equal(listed.account_requests_pagination, undefined, version); + const revision = listed.accounts.find(account => account.account_id === 'acct_1').revision; + assert.equal(revision, version === '3.2' ? 3 : undefined, version); + } + assert.equal(world.listRequestCalls.length, 0, 'listRequests never runs for an earlier release'); + const current = await call(server, 'list_accounts', { include_requests: true }); + assert.deepEqual( + current.account_requests.map(item => item.request_id), + ['areq_1'] + ); + }); + + test('listRequests needs accounts.list', () => { + const world = sellerWorld(); + assert.throws( + () => platformServer(world, { accounts: { list: undefined } }), + error => error instanceof PlatformConfigError && /listRequests/.test(error.message) + ); + }); + + test('requestAccount without listRequests warns that buyers cannot poll', t => { + const world = sellerWorld(); + const warnings = []; + const server = platformServer(world, { + accounts: { listRequests: undefined }, + options: { logger: { ...silent, warn: message => warnings.push(message) } }, + }); + t.after(() => server.close()); + assert.ok(warnings.some(message => /without accounts\.listRequests/.test(message))); + }); +}); + +describe('account.requests on createAdcpServer', () => { + test('a seller that declares no supported_billing is refused at construction, with the fix', () => { + // The account block's schema requires supported_billing; who bills is the seller's claim to make. + assert.throws( + () => + createAdcpServer({ + name: 'direct', + version: '1.0.0', + adcpVersion: ADCP_VERSION, + logger: silent, + capabilities: { supported_versions: ['3.2', WIRE], accountRequests: { changeableAttributes: ['timezone'] } }, + accounts: { requestAccount: sellerWorld().requestAccount }, + }), + /requires supported_billing\. Declare who bills: capabilities\.account\.supportedBilling/ + ); + }); + + test('a seller that declares supported_billing advertises requests alongside it', async t => { + const server = createAdcpServer({ + name: 'direct', + version: '1.0.0', + adcpVersion: ADCP_VERSION, + logger: silent, + capabilities: { + supported_versions: ['3.2', WIRE], + account: { supportedBilling: ['agent'] }, + accountRequests: { changeableAttributes: ['timezone'] }, + }, + validation: { requests: 'strict', responses: 'strict' }, + accounts: { requestAccount: sellerWorld().requestAccount }, + }); + t.after(() => server.close()); + const caps = await capabilities(server); + assert.deepEqual(caps.account.requests, { + supported: true, + request_task: 'request_account', + read_task: 'list_accounts', + changeable_attributes: ['timezone'], + }); + assert.deepEqual(caps.account.supported_billing, ['agent']); + assert.ok(caps.experimental_features.includes('account.requests')); + const legacy = await capabilities(server, '3.2'); + assert.equal(legacy.account?.requests, undefined, 'a release without requests sees no requests block'); + }); + + test('an adopter override keeps its optional fields; the task names stay fixed', async t => { + const server = createAdcpServer({ + name: 'direct', + version: '1.0.0', + adcpVersion: ADCP_VERSION, + logger: silent, + capabilities: { + account: { supportedBilling: ['operator'] }, + overrides: { account: { requests: { supported: true, pending_action_kinds: ['sign_up'] } } }, + }, + accounts: { requestAccount: sellerWorld().requestAccount }, + }); + t.after(() => server.close()); + const caps = await capabilities(server); + assert.deepEqual(caps.account.requests, { + supported: true, + request_task: 'request_account', + read_task: 'list_accounts', + pending_action_kinds: ['sign_up'], + }); + }); + + test('accountRequests without requestAccount is a configuration error', () => { + assert.throws( + () => + createAdcpServer({ + name: 'direct', + version: '1.0.0', + logger: silent, + capabilities: { accountRequests: { changeableAttributes: ['timezone'] } }, + accounts: {}, + }), + /register accounts\.requestAccount/ + ); + }); +}); + +describe('a TS buyer routes ensure through request_account against this server', () => { + async function connect(t, server) { + const [buyerTransport, sellerTransport] = InMemoryTransport.createLinkedPair(); + const mcp = new Client({ name: 'TS buyer', version: '1.0.0' }); + await server.connect(sellerTransport); + const dispatch = sellerTransport.onmessage; + sellerTransport.onmessage = (message, extra) => dispatch(message, { ...extra, ...AUTH }); + await mcp.connect(buyerTransport); + t.after(async () => { + await mcp.close(); + await server.close(); + }); + return AgentClient.fromMCPClient(mcp, { adcpVersion: ADCP_VERSION }); + } + + test('ready, then pending and refreshed to ready through list_accounts', async t => { + const world = sellerWorld(); + const buyer = await connect(t, platformServer(world)); + + const ready = await buyer.accounts.ensure( + { brand: { domain: 'ready.example' }, operator: 'agency.example' }, + { billing: 'operator' } + ); + assert.equal(ready.kind, 'ready'); + assert.equal(ready.via, 'request_account'); + assert.equal(ready.account_id, 'acct_ready'); + + const pending = await buyer.accounts.ensure( + { brand: { domain: 'review.example' }, operator: 'agency.example' }, + { billing: 'operator' } + ); + assert.equal(pending.kind, 'pending'); + assert.equal(pending.via, 'request_account'); + assert.equal(pending.action.message, 'Credit check'); + assert.deepEqual(await buyer.accounts.refresh(), [], 'nothing moved yet'); + + world.approve(pending.request_id); + const moved = await buyer.accounts.refresh(); + assert.deepEqual( + moved.map(outcome => [outcome.kind, outcome.account_id]), + [['ready', 'acct_review']] + ); + assert.ok(world.listRequestCalls.some(filter => filter.request_ids?.includes(pending.request_id))); + assert.equal(world.requests.size, 2, 'no duplicate request was opened'); + }); +}); diff --git a/test/lib/adcp33-beta-adoption.test.js b/test/lib/adcp33-beta-adoption.test.js index 7189f8f64..3b222de48 100644 --- a/test/lib/adcp33-beta-adoption.test.js +++ b/test/lib/adcp33-beta-adoption.test.js @@ -291,6 +291,8 @@ test('createAdcpServer serves raw request_account and media-buy issue handlers o version: '1.0.0', adcpVersion: ADCP_VERSION, validation: { requests: 'strict', responses: 'strict' }, + // request_account lives in the account block, which requires who bills. + capabilities: { account: { supportedBilling: ['agent'] } }, resolveAccount: async ref => (ref.account_id === 'acct_1' ? { id: ref.account_id } : null), accounts: { requestAccount: async params => { @@ -350,6 +352,8 @@ test('a change request may opt in to a separate account and resolve as created', version: '1.0.0', adcpVersion: ADCP_VERSION, validation: { requests: 'strict', responses: 'strict' }, + // request_account lives in the account block, which requires who bills. + capabilities: { account: { supportedBilling: ['agent'] } }, resolveAccount: async ref => (ref.account_id === 'acct_1' ? { id: ref.account_id } : null), accounts: { requestAccount: async params => {