From 5e3e7a8cb0531b7e1156aa8b4994a58132831d25 Mon Sep 17 00:00:00 2001 From: Emma Mulitz Date: Mon, 30 Mar 2026 22:34:00 -0400 Subject: [PATCH 1/6] feat: replace McpAdapter with thin Scope3McpClient for MCP consumers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The McpAdapter wrapped callTool in request() in typed resource methods — 3 layers of indirection over what the MCP server already provides. MCP consumers already have an MCP client; the server handles auth, routing, and validation. The SDK was an unnecessary abstraction layer. New architecture: - Scope3McpClient: thin connection helper (auth + URL) with direct callTool/readResource/listTools passthroughs - Scope3Client: REST-only, keeps typed resource methods where they make sense (humans, CLI, programmatic REST use) Net -490 lines. --- src/__tests__/adapters/mcp.test.ts | 311 ----------------------------- src/__tests__/client.test.ts | 22 +- src/__tests__/mcp-client.test.ts | 223 +++++++++++++++++++++ src/adapters/index.ts | 1 - src/adapters/mcp.ts | 240 ---------------------- src/client.ts | 58 +----- src/index.ts | 55 +++-- src/mcp-client.ts | 200 +++++++++++++++++++ src/types/index.ts | 9 +- 9 files changed, 474 insertions(+), 645 deletions(-) delete mode 100644 src/__tests__/adapters/mcp.test.ts create mode 100644 src/__tests__/mcp-client.test.ts delete mode 100644 src/adapters/mcp.ts create mode 100644 src/mcp-client.ts diff --git a/src/__tests__/adapters/mcp.test.ts b/src/__tests__/adapters/mcp.test.ts deleted file mode 100644 index 4e2643d..0000000 --- a/src/__tests__/adapters/mcp.test.ts +++ /dev/null @@ -1,311 +0,0 @@ -/** - * Tests for MCP adapter - */ - -import { McpAdapter } from '../../adapters/mcp'; -import { Scope3ApiError } from '../../adapters/base'; - -// Mock @modelcontextprotocol/sdk -const mockConnect = jest.fn(); -const mockClose = jest.fn(); -const mockCallTool = jest.fn(); - -jest.mock('@modelcontextprotocol/sdk/client/index.js', () => ({ - Client: jest.fn().mockImplementation(() => ({ - connect: mockConnect, - close: mockClose, - callTool: mockCallTool, - })), -})); - -jest.mock('@modelcontextprotocol/sdk/client/streamableHttp.js', () => ({ - StreamableHTTPClientTransport: jest.fn().mockImplementation(() => ({ - close: jest.fn(), - })), -})); - -describe('McpAdapter', () => { - beforeEach(() => { - mockConnect.mockReset(); - mockClose.mockReset(); - mockCallTool.mockReset(); - }); - - describe('initialization', () => { - it('should initialize with default values', () => { - const adapter = new McpAdapter({ apiKey: 'test-key', persona: 'buyer' }); - expect(adapter.baseUrl).toBe('https://api.agentic.scope3.com'); - expect(adapter.version).toBe('v2'); - expect(adapter.persona).toBe('buyer'); - expect(adapter.debug).toBe(false); - }); - - it('should use staging URL', () => { - const adapter = new McpAdapter({ - apiKey: 'test-key', - persona: 'buyer', - environment: 'staging', - }); - expect(adapter.baseUrl).toBe('https://api.agentic.staging.scope3.com'); - }); - - it('should use custom base URL', () => { - const adapter = new McpAdapter({ - apiKey: 'test-key', - persona: 'buyer', - baseUrl: 'https://custom.com', - }); - expect(adapter.baseUrl).toBe('https://custom.com'); - }); - - it('should enable debug mode', () => { - const adapter = new McpAdapter({ apiKey: 'test-key', persona: 'buyer', debug: true }); - expect(adapter.debug).toBe(true); - }); - }); - - describe('connect', () => { - it('should connect to MCP server', async () => { - const adapter = new McpAdapter({ apiKey: 'test-key', persona: 'buyer' }); - await adapter.connect(); - expect(mockConnect).toHaveBeenCalledTimes(1); - }); - - it('should not reconnect if already connected', async () => { - const adapter = new McpAdapter({ apiKey: 'test-key', persona: 'buyer' }); - await adapter.connect(); - await adapter.connect(); - expect(mockConnect).toHaveBeenCalledTimes(1); - }); - - it('should throw Scope3ApiError on connection failure', async () => { - mockConnect.mockRejectedValue(new Error('Connection refused')); - const adapter = new McpAdapter({ apiKey: 'test-key', persona: 'buyer' }); - await expect(adapter.connect()).rejects.toThrow(Scope3ApiError); - await expect(adapter.connect()).rejects.toMatchObject({ - status: 0, - }); - }); - }); - - describe('disconnect', () => { - it('should disconnect from MCP server', async () => { - const adapter = new McpAdapter({ apiKey: 'test-key', persona: 'buyer' }); - await adapter.connect(); - await adapter.disconnect(); - expect(mockClose).toHaveBeenCalledTimes(1); - }); - - it('should be no-op if not connected', async () => { - const adapter = new McpAdapter({ apiKey: 'test-key', persona: 'buyer' }); - await adapter.disconnect(); - expect(mockClose).not.toHaveBeenCalled(); - }); - }); - - describe('request - api_call tool', () => { - let adapter: McpAdapter; - - beforeEach(async () => { - adapter = new McpAdapter({ apiKey: 'test-key', persona: 'buyer' }); - mockCallTool.mockResolvedValue({ - structuredContent: { id: '123' }, - }); - }); - - it('should call api_call tool for GET /advertisers', async () => { - await adapter.request('GET', '/advertisers'); - expect(mockCallTool).toHaveBeenCalledWith({ - name: 'api_call', - arguments: { - method: 'GET', - path: '/api/v2/buyer/advertisers', - }, - }); - }); - - it('should call api_call tool for POST /advertisers with body', async () => { - await adapter.request('POST', '/advertisers', { name: 'Test' }); - expect(mockCallTool).toHaveBeenCalledWith({ - name: 'api_call', - arguments: { - method: 'POST', - path: '/api/v2/buyer/advertisers', - body: { name: 'Test' }, - }, - }); - }); - - it('should call api_call tool for GET /advertisers/123', async () => { - await adapter.request('GET', '/advertisers/123'); - expect(mockCallTool).toHaveBeenCalledWith({ - name: 'api_call', - arguments: { - method: 'GET', - path: '/api/v2/buyer/advertisers/123', - }, - }); - }); - - it('should append query params to path', async () => { - await adapter.request('GET', '/campaigns', undefined, { - params: { take: 10, status: 'ACTIVE' }, - }); - expect(mockCallTool).toHaveBeenCalledWith({ - name: 'api_call', - arguments: { - method: 'GET', - path: expect.stringContaining('/api/v2/buyer/campaigns?'), - }, - }); - const callArgs = mockCallTool.mock.calls[0][0]; - expect(callArgs.arguments.path).toContain('take=10'); - expect(callArgs.arguments.path).toContain('status=ACTIVE'); - }); - - it('should skip undefined query params', async () => { - await adapter.request('GET', '/campaigns', undefined, { - params: { take: 10, status: undefined }, - }); - const callArgs = mockCallTool.mock.calls[0][0]; - expect(callArgs.arguments.path).toContain('take=10'); - expect(callArgs.arguments.path).not.toContain('status'); - }); - - it('should not include body for GET requests', async () => { - await adapter.request('GET', '/advertisers'); - const callArgs = mockCallTool.mock.calls[0][0]; - expect(callArgs.arguments.body).toBeUndefined(); - }); - - it('should include body for PUT requests', async () => { - await adapter.request('PUT', '/advertisers/123', { name: 'Updated' }); - expect(mockCallTool).toHaveBeenCalledWith({ - name: 'api_call', - arguments: { - method: 'PUT', - path: '/api/v2/buyer/advertisers/123', - body: { name: 'Updated' }, - }, - }); - }); - - it('should include body for DELETE requests', async () => { - await adapter.request('DELETE', '/bundles/123/products', { productIds: ['p1'] }); - expect(mockCallTool).toHaveBeenCalledWith({ - name: 'api_call', - arguments: { - method: 'DELETE', - path: '/api/v2/buyer/bundles/123/products', - body: { productIds: ['p1'] }, - }, - }); - }); - - it('should use partner persona in path', async () => { - const partnerAdapter = new McpAdapter({ apiKey: 'test-key', persona: 'partner' }); - mockCallTool.mockResolvedValue({ structuredContent: {} }); - await partnerAdapter.request('GET', '/partners'); - expect(mockCallTool).toHaveBeenCalledWith({ - name: 'api_call', - arguments: { - method: 'GET', - path: '/api/v2/partner/partners', - }, - }); - }); - - it('should map latest version to v2 in path', async () => { - const latestAdapter = new McpAdapter({ - apiKey: 'test-key', - persona: 'buyer', - version: 'latest', - }); - mockCallTool.mockResolvedValue({ structuredContent: {} }); - await latestAdapter.request('GET', '/advertisers'); - expect(mockCallTool).toHaveBeenCalledWith({ - name: 'api_call', - arguments: { - method: 'GET', - path: '/api/v2/buyer/advertisers', - }, - }); - }); - }); - - describe('request - response handling', () => { - let adapter: McpAdapter; - - beforeEach(() => { - adapter = new McpAdapter({ apiKey: 'test-key', persona: 'buyer' }); - }); - - it('should return structuredContent when available', async () => { - mockCallTool.mockResolvedValue({ - structuredContent: { id: '123', name: 'Test' }, - }); - - const result = await adapter.request('GET', '/advertisers/123'); - expect(result).toEqual({ id: '123', name: 'Test' }); - }); - - it('should parse text content as JSON', async () => { - mockCallTool.mockResolvedValue({ - content: [{ type: 'text', text: '{"id":"123"}' }], - }); - - const result = await adapter.request('GET', '/advertisers/123'); - expect(result).toEqual({ id: '123' }); - }); - - it('should return text content as message when not JSON', async () => { - mockCallTool.mockResolvedValue({ - content: [{ type: 'text', text: 'Campaign executed successfully' }], - }); - - const result = await adapter.request<{ message: string }>('POST', '/campaigns/123/execute'); - expect(result).toEqual({ message: 'Campaign executed successfully' }); - }); - - it('should throw when no content returned', async () => { - mockCallTool.mockResolvedValue({}); - - await expect(adapter.request('GET', '/advertisers/123')).rejects.toThrow(Scope3ApiError); - await expect(adapter.request('GET', '/advertisers/123')).rejects.toMatchObject({ - status: 500, - message: 'MCP returned no content', - }); - }); - - it('should wrap non-Scope3ApiError errors', async () => { - mockCallTool.mockRejectedValue(new Error('MCP timeout')); - - await expect(adapter.request('GET', '/advertisers')).rejects.toThrow(Scope3ApiError); - await expect(adapter.request('GET', '/advertisers')).rejects.toMatchObject({ - status: 500, - message: expect.stringContaining('MCP timeout'), - }); - }); - - it('should re-throw Scope3ApiError as-is', async () => { - mockCallTool.mockRejectedValue(new Scope3ApiError(404, 'Not found')); - - await expect(adapter.request('GET', '/advertisers/999')).rejects.toMatchObject({ - status: 404, - message: 'Not found', - }); - }); - }); - - describe('request - auto connect', () => { - it('should auto-connect on first request', async () => { - const adapter = new McpAdapter({ apiKey: 'test-key', persona: 'buyer' }); - mockCallTool.mockResolvedValue({ - structuredContent: { items: [] }, - }); - - await adapter.request('GET', '/advertisers'); - expect(mockConnect).toHaveBeenCalledTimes(1); - }); - }); -}); diff --git a/src/__tests__/client.test.ts b/src/__tests__/client.test.ts index 5b983d3..9618e40 100644 --- a/src/__tests__/client.test.ts +++ b/src/__tests__/client.test.ts @@ -1,5 +1,5 @@ /** - * Tests for Scope3Client + * Tests for Scope3Client (REST-only) */ import { Scope3Client } from '../client'; @@ -163,18 +163,6 @@ describe('Scope3Client', () => { }); }); - describe('adapter selection', () => { - it('should default to REST adapter', () => { - const client = new Scope3Client({ apiKey: 'test-key', persona: 'buyer' }); - expect(client.baseUrl).toBe('https://api.agentic.scope3.com'); - }); - - it('should use MCP adapter when specified', () => { - const client = new Scope3Client({ apiKey: 'test-key', persona: 'buyer', adapter: 'mcp' }); - expect(client.baseUrl).toBe('https://api.agentic.scope3.com'); - }); - }); - describe('version handling', () => { it('should support latest version', () => { const client = new Scope3Client({ apiKey: 'test-key', persona: 'buyer', version: 'latest' }); @@ -187,14 +175,6 @@ describe('Scope3Client', () => { }); }); - describe('connect/disconnect', () => { - it('should connect and disconnect without error for REST', async () => { - const client = new Scope3Client({ apiKey: 'test-key', persona: 'buyer' }); - await expect(client.connect()).resolves.toBeUndefined(); - await expect(client.disconnect()).resolves.toBeUndefined(); - }); - }); - describe('debug mode', () => { it('should default to debug off', () => { const client = new Scope3Client({ apiKey: 'test-key', persona: 'buyer' }); diff --git a/src/__tests__/mcp-client.test.ts b/src/__tests__/mcp-client.test.ts new file mode 100644 index 0000000..2ef58ef --- /dev/null +++ b/src/__tests__/mcp-client.test.ts @@ -0,0 +1,223 @@ +/** + * Tests for Scope3McpClient - thin MCP connection helper + */ + +import { Scope3McpClient } from '../mcp-client'; +import { Scope3ApiError } from '../adapters/base'; + +// Mock @modelcontextprotocol/sdk +const mockConnect = jest.fn(); +const mockClose = jest.fn(); +const mockCallTool = jest.fn(); +const mockReadResource = jest.fn(); +const mockListTools = jest.fn(); + +jest.mock('@modelcontextprotocol/sdk/client/index.js', () => ({ + Client: jest.fn().mockImplementation(() => ({ + connect: mockConnect, + close: mockClose, + callTool: mockCallTool, + readResource: mockReadResource, + listTools: mockListTools, + })), +})); + +jest.mock('@modelcontextprotocol/sdk/client/streamableHttp.js', () => ({ + StreamableHTTPClientTransport: jest.fn().mockImplementation(() => ({ + close: jest.fn(), + })), +})); + +describe('Scope3McpClient', () => { + beforeEach(() => { + mockConnect.mockReset(); + mockClose.mockReset(); + mockCallTool.mockReset(); + mockReadResource.mockReset(); + mockListTools.mockReset(); + }); + + describe('initialization', () => { + it('should require apiKey', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(() => new Scope3McpClient({ apiKey: '' } as any)).toThrow('apiKey is required'); + }); + + it('should default to production URL', () => { + const client = new Scope3McpClient({ apiKey: 'test-key' }); + expect(client.baseUrl).toBe('https://api.agentic.scope3.com'); + }); + + it('should use staging URL', () => { + const client = new Scope3McpClient({ apiKey: 'test-key', environment: 'staging' }); + expect(client.baseUrl).toBe('https://api.agentic.staging.scope3.com'); + }); + + it('should use custom base URL', () => { + const client = new Scope3McpClient({ apiKey: 'test-key', baseUrl: 'https://custom.com' }); + expect(client.baseUrl).toBe('https://custom.com'); + }); + + it('should strip trailing slash from base URL', () => { + const client = new Scope3McpClient({ apiKey: 'test-key', baseUrl: 'https://custom.com/' }); + expect(client.baseUrl).toBe('https://custom.com'); + }); + + it('should not be connected initially', () => { + const client = new Scope3McpClient({ apiKey: 'test-key' }); + expect(client.isConnected).toBe(false); + }); + }); + + describe('connect', () => { + it('should connect to MCP server', async () => { + const client = new Scope3McpClient({ apiKey: 'test-key' }); + await client.connect(); + expect(mockConnect).toHaveBeenCalledTimes(1); + expect(client.isConnected).toBe(true); + }); + + it('should not reconnect if already connected', async () => { + const client = new Scope3McpClient({ apiKey: 'test-key' }); + await client.connect(); + await client.connect(); + expect(mockConnect).toHaveBeenCalledTimes(1); + }); + + it('should throw Scope3ApiError on connection failure', async () => { + mockConnect.mockRejectedValue(new Error('Connection refused')); + const client = new Scope3McpClient({ apiKey: 'test-key' }); + await expect(client.connect()).rejects.toThrow(Scope3ApiError); + }); + + it('should allow retry after connection failure', async () => { + mockConnect.mockRejectedValueOnce(new Error('Connection refused')); + const client = new Scope3McpClient({ apiKey: 'test-key' }); + await expect(client.connect()).rejects.toThrow(); + + mockConnect.mockResolvedValueOnce(undefined); + await client.connect(); + expect(client.isConnected).toBe(true); + }); + }); + + describe('disconnect', () => { + it('should disconnect from MCP server', async () => { + const client = new Scope3McpClient({ apiKey: 'test-key' }); + await client.connect(); + await client.disconnect(); + expect(mockClose).toHaveBeenCalledTimes(1); + expect(client.isConnected).toBe(false); + }); + + it('should be no-op if not connected', async () => { + const client = new Scope3McpClient({ apiKey: 'test-key' }); + await client.disconnect(); + expect(mockClose).not.toHaveBeenCalled(); + }); + }); + + describe('callTool', () => { + it('should auto-connect on first call', async () => { + const client = new Scope3McpClient({ apiKey: 'test-key' }); + mockCallTool.mockResolvedValue({ content: [{ type: 'text', text: 'ok' }] }); + + await client.callTool('health'); + expect(mockConnect).toHaveBeenCalledTimes(1); + }); + + it('should pass tool name and arguments directly', async () => { + const client = new Scope3McpClient({ apiKey: 'test-key' }); + mockCallTool.mockResolvedValue({ content: [] }); + + await client.callTool('api_call', { + method: 'GET', + path: '/api/v2/buyer/advertisers', + }); + + expect(mockCallTool).toHaveBeenCalledWith({ + name: 'api_call', + arguments: { + method: 'GET', + path: '/api/v2/buyer/advertisers', + }, + }); + }); + + it('should call tools without arguments', async () => { + const client = new Scope3McpClient({ apiKey: 'test-key' }); + mockCallTool.mockResolvedValue({ content: [{ type: 'text', text: 'healthy' }] }); + + await client.callTool('health'); + + expect(mockCallTool).toHaveBeenCalledWith({ + name: 'health', + arguments: undefined, + }); + }); + + it('should return raw CallToolResult', async () => { + const client = new Scope3McpClient({ apiKey: 'test-key' }); + const expected = { + content: [{ type: 'text', text: '{"id":"123"}' }], + structuredContent: { id: '123' }, + }; + mockCallTool.mockResolvedValue(expected); + + const result = await client.callTool('api_call', { + method: 'GET', + path: '/api/v2/buyer/advertisers/123', + }); + + expect(result).toEqual(expected); + }); + }); + + describe('readResource', () => { + it('should auto-connect on first call', async () => { + const client = new Scope3McpClient({ apiKey: 'test-key' }); + mockReadResource.mockResolvedValue({ contents: [] }); + + await client.readResource('scope3://schema/advertiser'); + expect(mockConnect).toHaveBeenCalledTimes(1); + }); + + it('should pass URI directly', async () => { + const client = new Scope3McpClient({ apiKey: 'test-key' }); + mockReadResource.mockResolvedValue({ contents: [] }); + + await client.readResource('scope3://schema/advertiser'); + + expect(mockReadResource).toHaveBeenCalledWith({ + uri: 'scope3://schema/advertiser', + }); + }); + }); + + describe('listTools', () => { + it('should auto-connect on first call', async () => { + const client = new Scope3McpClient({ apiKey: 'test-key' }); + mockListTools.mockResolvedValue({ tools: [] }); + + await client.listTools(); + expect(mockConnect).toHaveBeenCalledTimes(1); + }); + + it('should return available tools', async () => { + const client = new Scope3McpClient({ apiKey: 'test-key' }); + const expected = { + tools: [ + { name: 'api_call', description: 'Make API calls' }, + { name: 'ask_about_capability', description: 'Ask about capabilities' }, + { name: 'help', description: 'Get help' }, + { name: 'health', description: 'Health check' }, + ], + }; + mockListTools.mockResolvedValue(expected); + + const result = await client.listTools(); + expect(result.tools).toHaveLength(4); + expect(result.tools[0].name).toBe('api_call'); + }); + }); +}); diff --git a/src/adapters/index.ts b/src/adapters/index.ts index fafcb04..e8eb7cf 100644 --- a/src/adapters/index.ts +++ b/src/adapters/index.ts @@ -4,4 +4,3 @@ export * from './base'; export { RestAdapter } from './rest'; -export { McpAdapter } from './mcp'; diff --git a/src/adapters/mcp.ts b/src/adapters/mcp.ts deleted file mode 100644 index 6362250..0000000 --- a/src/adapters/mcp.ts +++ /dev/null @@ -1,240 +0,0 @@ -/** - * MCP (Model Context Protocol) adapter for Scope3 API - * Primary adapter for AI agents (Claude, etc.) - */ - -import { Client } from '@modelcontextprotocol/sdk/client/index.js'; -import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; -import type { ApiVersion, Persona, Scope3ClientConfig } from '../types'; -import type { ValidateMode } from '../validation'; -import { - BaseAdapter, - HttpMethod, - RequestOptions, - Scope3ApiError, - resolveBaseUrl, - resolveVersion, - resolvePersona, - sanitizeForLogging, -} from './base'; -import { logger } from '../utils/logger'; - -const SDK_VERSION = '2.0.0'; - -/** - * MCP adapter implementation using Model Context Protocol - * Uses the single `api_call` tool to make REST-style requests - */ -export class McpAdapter implements BaseAdapter { - readonly baseUrl: string; - readonly version: ApiVersion; - readonly persona: Persona; - readonly debug: boolean; - readonly validate: ValidateMode | undefined; - - private readonly apiKey: string; - private mcpClient: Client; - private transport: StreamableHTTPClientTransport; - private connected = false; - private connectPromise: Promise | null = null; - - constructor(config: Scope3ClientConfig) { - this.apiKey = config.apiKey; - this.baseUrl = resolveBaseUrl(config); - this.version = resolveVersion(config); - this.persona = resolvePersona(config); - this.debug = config.debug ?? false; - this.validate = config.validate ?? true; - - // Initialize MCP client - this.mcpClient = new Client( - { - name: 'scope3-sdk', - version: SDK_VERSION, - }, - { - capabilities: { - tools: {}, - }, - } - ); - - // Initialize transport - this.transport = new StreamableHTTPClientTransport(new URL(`${this.baseUrl}/mcp`), { - requestInit: { - headers: { - Authorization: `Bearer ${this.apiKey}`, - }, - }, - }); - - if (this.debug) { - logger.setDebug(true); - logger.debug('McpAdapter initialized', { - baseUrl: this.baseUrl, - version: this.version, - persona: this.persona, - }); - } - } - - /** - * Connect to MCP server - */ - async connect(): Promise { - if (this.connected) { - return; - } - if (!this.connectPromise) { - this.connectPromise = this.doConnect(); - } - return this.connectPromise; - } - - private async doConnect(): Promise { - try { - await this.mcpClient.connect(this.transport); - this.connected = true; - - if (this.debug) { - logger.debug('MCP connected'); - } - } catch (error) { - this.connectPromise = null; // Allow retry on failure - throw new Scope3ApiError( - 0, - `Failed to connect to MCP server: ${error instanceof Error ? error.message : 'Unknown error'}` - ); - } - } - - /** - * Disconnect from MCP server - */ - async disconnect(): Promise { - if (!this.connected) { - return; - } - - try { - await this.mcpClient.close(); - await this.transport.close(); - this.connected = false; - - if (this.debug) { - logger.debug('MCP disconnected'); - } - } catch (error) { - logger.error('Error disconnecting from MCP', error); - } - } - - /** - * Make an API request via MCP using the api_call tool - */ - async request( - method: HttpMethod, - path: string, - body?: unknown, - options?: RequestOptions - ): Promise { - // Ensure connected - if (!this.connected) { - await this.connect(); - } - - const startTime = Date.now(); - - // Build full API path with version and persona - const versionPath = this.version === 'latest' ? 'v2' : this.version; - let fullPath = `/api/${versionPath}/${this.persona}${path}`; - - // Add query parameters to path - if (options?.params) { - const params = new URLSearchParams(); - for (const [key, value] of Object.entries(options.params)) { - if (value !== undefined) { - params.append(key, String(value)); - } - } - const queryString = params.toString(); - if (queryString) { - fullPath += `?${queryString}`; - } - } - - // Build MCP tool arguments for api_call - const args: Record = { - method, - path: fullPath, - ...(body && typeof body === 'object' ? { body } : {}), - }; - - if (this.debug) { - logger.debug('MCP Request', { - tool: 'api_call', - args: sanitizeForLogging(args), - }); - } - - try { - const result = await this.mcpClient.callTool({ - name: 'api_call', - arguments: args, - }); - - // Check for MCP-level errors - if (result.isError) { - const errorContent = result.content as Array<{ type: string; text?: string }> | undefined; - const errorMessage = errorContent?.[0]?.text ?? 'MCP tool call failed'; - try { - const parsed = JSON.parse(errorMessage); - throw new Scope3ApiError( - parsed.status ?? parsed.code ?? 500, - parsed.message ?? errorMessage, - parsed.details - ); - } catch (e) { - if (e instanceof Scope3ApiError) throw e; - throw new Scope3ApiError(500, errorMessage); - } - } - - const durationMs = Date.now() - startTime; - - if (this.debug) { - logger.debug('MCP Response', { - durationMs, - hasStructuredContent: !!result.structuredContent, - }); - } - - // Extract structured content - if (result.structuredContent) { - return result.structuredContent as T; - } - - // Fall back to text content if no structured content - const content = result.content as Array<{ type: string; text?: string }> | undefined; - if (content && content.length > 0 && content[0].type === 'text' && content[0].text) { - try { - return JSON.parse(content[0].text) as T; - } catch { - // Return as message - return { message: content[0].text } as T; - } - } - - throw new Scope3ApiError(500, 'MCP returned no content'); - } catch (error) { - if (error instanceof Scope3ApiError) { - throw error; - } - - throw new Scope3ApiError( - 500, - `MCP error: ${error instanceof Error ? error.message : 'Unknown error'}` - ); - } - } -} diff --git a/src/client.ts b/src/client.ts index 0720107..fe7e5e6 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,14 +1,15 @@ /** - * Scope3Client - Unified client for the Scope3 Agentic Platform + * Scope3Client - REST client for the Scope3 Agentic Platform * - * Supports both REST (for humans/CLI) and MCP (for AI agents) adapters. - * Requires a persona to determine which API surface to use. + * Provides typed resource methods for REST consumers (humans, CLI, programmatic use). + * + * For MCP consumers (AI agents), use Scope3McpClient instead — it's a thin + * connection helper that gives you direct access to callTool/readResource + * without unnecessary abstraction layers. */ import type { Scope3ClientConfig, ApiVersion, Persona } from './types'; -import { BaseAdapter } from './adapters/base'; import { RestAdapter } from './adapters/rest'; -import { McpAdapter } from './adapters/mcp'; import { AdvertisersResource } from './resources/advertisers'; import { CampaignsResource } from './resources/campaigns'; import { BundlesResource } from './resources/bundles'; @@ -20,14 +21,14 @@ import { AgentsResource } from './resources/agents'; import { fetchSkillMd, parseSkillMd, ParsedSkill } from './skill'; /** - * Main client for interacting with the Scope3 Agentic Platform + * REST client for interacting with the Scope3 Agentic Platform. + * Provides typed resource methods for each API surface. * * @example * ```typescript * // Buyer persona * const client = new Scope3Client({ apiKey: 'token', persona: 'buyer' }); * const advertisers = await client.advertisers.list(); - * const bundle = await client.bundles.create({ advertiserId: '123', channels: ['display'] }); * * // Partner persona * const partnerClient = new Scope3Client({ apiKey: 'token', persona: 'partner' }); @@ -47,16 +48,11 @@ export class Scope3Client { private _partners?: PartnersResource; private _agents?: AgentsResource; - /** The adapter used for API communication */ - private readonly adapter: BaseAdapter; + private readonly adapter: RestAdapter; - /** API version being used */ public readonly version: ApiVersion; - - /** API persona being used */ public readonly persona: Persona; - /** Cached parsed skill.md */ private skillPromise: Promise | null = null; constructor(config: Scope3ClientConfig) { @@ -69,15 +65,8 @@ export class Scope3Client { this.version = config.version ?? 'v2'; this.persona = config.persona; + this.adapter = new RestAdapter(config); - // Select adapter based on config - if (config.adapter === 'mcp') { - this.adapter = new McpAdapter(config); - } else { - this.adapter = new RestAdapter(config); - } - - // Initialize persona-specific resources switch (this.persona) { case 'buyer': this._advertisers = new AdvertisersResource(this.adapter); @@ -96,7 +85,6 @@ export class Scope3Client { // ── Buyer persona resources ────────────────────────────────────── - /** Advertiser management (buyer persona) */ get advertisers(): AdvertisersResource { if (!this._advertisers) { throw new Error('advertisers is only available with the buyer persona'); @@ -104,7 +92,6 @@ export class Scope3Client { return this._advertisers; } - /** Campaign management (buyer persona) */ get campaigns(): CampaignsResource { if (!this._campaigns) { throw new Error('campaigns is only available with the buyer persona'); @@ -112,7 +99,6 @@ export class Scope3Client { return this._campaigns; } - /** Bundle management for inventory selection (buyer persona) */ get bundles(): BundlesResource { if (!this._bundles) { throw new Error('bundles is only available with the buyer persona'); @@ -120,7 +106,6 @@ export class Scope3Client { return this._bundles; } - /** Signal discovery (buyer persona) */ get signals(): SignalsResource { if (!this._signals) { throw new Error('signals is only available with the buyer persona'); @@ -128,7 +113,6 @@ export class Scope3Client { return this._signals; } - /** Reporting metrics (buyer persona) */ get reporting(): ReportingResource { if (!this._reporting) { throw new Error('reporting is only available with the buyer persona'); @@ -136,7 +120,6 @@ export class Scope3Client { return this._reporting; } - /** Sales agents (buyer persona) */ get salesAgents(): SalesAgentsResource { if (!this._salesAgents) { throw new Error('salesAgents is only available with the buyer persona'); @@ -146,7 +129,6 @@ export class Scope3Client { // ── Partner persona resources ──────────────────────────────────── - /** Partner management (partner persona) */ get partners(): PartnersResource { if (!this._partners) { throw new Error('partners is only available with the partner persona'); @@ -154,7 +136,6 @@ export class Scope3Client { return this._partners; } - /** Agent management (partner persona) */ get agents(): AgentsResource { if (!this._agents) { throw new Error('agents is only available with the partner persona'); @@ -164,9 +145,6 @@ export class Scope3Client { // ── Shared methods ─────────────────────────────────────────────── - /** - * Get the parsed skill.md for this persona and API version - */ async getSkill(): Promise { if (!this.skillPromise) { this.skillPromise = fetchSkillMd({ @@ -183,26 +161,10 @@ export class Scope3Client { return this.skillPromise; } - /** - * Connect to the API (required for MCP adapter) - */ - async connect(): Promise { - await this.adapter.connect(); - } - - /** - * Disconnect from the API (for cleanup) - */ - async disconnect(): Promise { - await this.adapter.disconnect(); - } - - /** Get the base URL being used */ get baseUrl(): string { return this.adapter.baseUrl; } - /** Check if debug mode is enabled */ get debug(): boolean { return this.adapter.debug; } diff --git a/src/index.ts b/src/index.ts index 9b3780e..17bd091 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,32 +1,48 @@ /** - * Scope3 SDK - REST and MCP client for the Agentic Platform + * Scope3 SDK for the Agentic Platform * - * Supports 2 personas: buyer and partner. + * Two entry points for two audiences: + * + * 1. REST consumers (humans, CLI, programmatic) → Scope3Client + * Typed resource methods: client.advertisers.list(), client.campaigns.create(), etc. + * + * 2. MCP consumers (AI agents) → Scope3McpClient + * Thin connection helper: connect, callTool, readResource, listTools. + * The MCP server already handles auth, routing, and validation — + * this just wires up the connection and gets out of the way. * * @example * ```typescript + * // REST consumer * import { Scope3Client } from 'scope3'; + * const client = new Scope3Client({ apiKey: 'sk_xxx', persona: 'buyer' }); + * const advertisers = await client.advertisers.list(); * - * // Buyer persona - * const buyer = new Scope3Client({ apiKey: 'sk_xxx', persona: 'buyer' }); - * const advertisers = await buyer.advertisers.list(); - * - * // Partner persona - * const partner = new Scope3Client({ apiKey: 'sk_xxx', persona: 'partner' }); - * const partners = await partner.partners.list(); + * // MCP consumer (AI agent) + * import { Scope3McpClient } from 'scope3'; + * const mcp = new Scope3McpClient({ apiKey: 'sk_xxx' }); + * await mcp.connect(); + * const result = await mcp.callTool('api_call', { method: 'GET', path: '/api/v2/buyer/advertisers' }); * ``` */ -// Main client +// ── Clients ──────────────────────────────────────────────────────── + +// REST client with typed resource methods export { Scope3Client } from './client'; -// Adapters +// MCP client — thin connection helper for AI agents +export { Scope3McpClient } from './mcp-client'; +export type { Scope3McpClientConfig, CallToolResult, ReadResourceResult } from './mcp-client'; + +// ── REST Adapter (for advanced use) ──────────────────────────────── + export { RestAdapter } from './adapters/rest'; -export { McpAdapter } from './adapters/mcp'; export { Scope3ApiError } from './adapters/base'; export type { BaseAdapter } from './adapters/base'; -// Resources +// ── Resources (used by Scope3Client, exported for typing) ────────── + export { AdvertisersResource, AgentsResource, @@ -42,29 +58,32 @@ export { TestCohortsResource, } from './resources'; -// skill.md support +// ── skill.md support ─────────────────────────────────────────────── + export { fetchSkillMd, parseSkillMd, getBundledSkillMd } from './skill'; export type { ParsedSkill, SkillCommand, SkillParameter, SkillExample } from './skill'; -// Webhook server (optional) +// ── Webhook server ───────────────────────────────────────────────── + export { WebhookServer } from './webhook-server'; export type { WebhookEvent, WebhookHandler, WebhookServerConfig } from './webhook-server'; -// Validation +// ── Validation (Zod schemas for optional client-side validation) ─── + export { validateInput, validateResponse } from './validation'; export type { ValidateMode } from './validation'; // Schemas (auto-generated from OpenAPI spec) export * from './schemas'; -// Types +// ── Types ────────────────────────────────────────────────────────── + export type { // Config Scope3ClientConfig, ApiVersion, Persona, Environment, - AdapterType, // API Response Wrappers ApiResponse, PaginatedApiResponse, diff --git a/src/mcp-client.ts b/src/mcp-client.ts new file mode 100644 index 0000000..805566d --- /dev/null +++ b/src/mcp-client.ts @@ -0,0 +1,200 @@ +/** + * Scope3McpClient - Thin connection helper for MCP consumers + * + * MCP consumers (Claude, ChatGPT, Cursor, etc.) already have an MCP client. + * The server handles auth, routing, and validation. This client just wires up + * the connection with correct auth and URL, then gets out of the way. + * + * The v2 buyer MCP surface is 4 tools: api_call, ask_about_capability, help, health. + * This client gives you direct access to all of them — no typed resource wrappers, + * no request() indirection, no adapter pattern. + * + * @example + * ```typescript + * const mcp = new Scope3McpClient({ apiKey: 'sk_xxx' }); + * await mcp.connect(); + * + * // Call tools directly — same as the MCP server exposes them + * const result = await mcp.callTool('api_call', { + * method: 'GET', + * path: '/api/v2/buyer/advertisers', + * }); + * + * // Ask what the API can do + * const capabilities = await mcp.callTool('ask_about_capability', { + * question: 'How do I create a campaign?', + * }); + * + * // List available tools + * const tools = await mcp.listTools(); + * + * // Read a resource + * const resource = await mcp.readResource('scope3://schema/advertiser'); + * + * await mcp.disconnect(); + * ``` + */ + +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import type { CallToolResult, ReadResourceResult } from '@modelcontextprotocol/sdk/types.js'; +import type { ApiVersion, Persona, Environment } from './types'; +import { Scope3ApiError, getDefaultBaseUrl } from './adapters/base'; +import { logger } from './utils/logger'; + +// Re-export MCP types for consumers +export type { CallToolResult, ReadResourceResult }; + +const SDK_VERSION = '2.1.0'; + +export interface Scope3McpClientConfig { + /** API key (Bearer token) for authentication */ + apiKey: string; + /** API persona — determines which MCP surface to connect to */ + persona?: Persona; + /** API version (default: 'v2') */ + version?: ApiVersion; + /** Environment (default: 'production') */ + environment?: Environment; + /** Custom base URL (overrides environment) */ + baseUrl?: string; + /** Enable debug logging */ + debug?: boolean; +} + +/** + * Thin MCP connection helper for AI agent consumers. + * + * Connects to the Scope3 MCP server with auth, then exposes callTool(), + * readResource(), and listTools() as direct passthroughs. No typed resource + * wrappers, no adapter indirection. + */ +export class Scope3McpClient { + readonly baseUrl: string; + + private readonly mcpClient: Client; + private readonly transport: StreamableHTTPClientTransport; + private connected = false; + private connectPromise: Promise | null = null; + private readonly debugMode: boolean; + + constructor(config: Scope3McpClientConfig) { + if (!config.apiKey) { + throw new Error('apiKey is required'); + } + + this.debugMode = config.debug ?? false; + this.baseUrl = config.baseUrl?.replace(/\/$/, '') ?? getDefaultBaseUrl(config.environment); + + this.mcpClient = new Client( + { name: 'scope3-sdk', version: SDK_VERSION }, + { capabilities: { tools: {} } } + ); + + // Build MCP endpoint URL — the server routes based on auth token + this.transport = new StreamableHTTPClientTransport(new URL(`${this.baseUrl}/mcp`), { + requestInit: { + headers: { + Authorization: `Bearer ${config.apiKey}`, + }, + }, + }); + + if (this.debugMode) { + logger.setDebug(true); + logger.debug('Scope3McpClient initialized', { baseUrl: this.baseUrl }); + } + } + + /** + * Connect to the MCP server. Called automatically on first tool call. + */ + async connect(): Promise { + if (this.connected) return; + if (!this.connectPromise) { + this.connectPromise = this.doConnect(); + } + return this.connectPromise; + } + + private async doConnect(): Promise { + try { + await this.mcpClient.connect(this.transport); + this.connected = true; + if (this.debugMode) { + logger.debug('MCP connected'); + } + } catch (error) { + this.connectPromise = null; + throw new Scope3ApiError( + 0, + `Failed to connect to MCP server: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } + } + + /** + * Disconnect from the MCP server. + */ + async disconnect(): Promise { + if (!this.connected) return; + try { + await this.mcpClient.close(); + await this.transport.close(); + this.connected = false; + if (this.debugMode) { + logger.debug('MCP disconnected'); + } + } catch (error) { + logger.error('Error disconnecting from MCP', error); + } + } + + /** + * Call an MCP tool directly. Auto-connects on first call. + * + * The v2 buyer surface exposes: api_call, ask_about_capability, help, health. + */ + async callTool(name: string, args?: Record): Promise { + if (!this.connected) await this.connect(); + + if (this.debugMode) { + logger.debug('callTool', { name, args }); + } + + const result = await this.mcpClient.callTool({ + name, + arguments: args, + }); + + return result as CallToolResult; + } + + /** + * Read an MCP resource directly. Auto-connects on first call. + */ + async readResource(uri: string): Promise { + if (!this.connected) await this.connect(); + + if (this.debugMode) { + logger.debug('readResource', { uri }); + } + + return this.mcpClient.readResource({ uri }); + } + + /** + * List all available MCP tools. Auto-connects on first call. + */ + async listTools(): Promise<{ + tools: Array<{ name: string; description?: string; inputSchema?: unknown }>; + }> { + if (!this.connected) await this.connect(); + return this.mcpClient.listTools(); + } + + /** Whether the client is currently connected */ + get isConnected(): boolean { + return this.connected; + } +} diff --git a/src/types/index.ts b/src/types/index.ts index c5e86f2..412309f 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -15,11 +15,10 @@ export type Persona = 'buyer' | 'partner'; /** Environment for API endpoints */ export type Environment = 'production' | 'staging'; -/** Adapter type for communication protocol */ -export type AdapterType = 'rest' | 'mcp'; - /** - * Configuration for Scope3Client + * Configuration for Scope3Client (REST client) + * + * For MCP consumers, use Scope3McpClient with Scope3McpClientConfig instead. */ export interface Scope3ClientConfig { /** API key (Bearer token) for authentication */ @@ -32,8 +31,6 @@ export interface Scope3ClientConfig { environment?: Environment; /** Custom base URL (overrides environment) */ baseUrl?: string; - /** Adapter type: 'rest' for HTTP, 'mcp' for AI agents (default: 'rest') */ - adapter?: AdapterType; /** Request timeout in ms (default: 30000) */ timeout?: number; /** Enable debug logging */ From f1e73d78933f600b09780df08151f113e910cef0 Mon Sep 17 00:00:00 2001 From: Emma Mulitz Date: Mon, 30 Mar 2026 22:39:48 -0400 Subject: [PATCH 2/6] fix: address review findings in Scope3McpClient - Fix reconnect lifecycle: recreate transport/client in doConnect(), reset all state in disconnect() via finally block - Add sanitizeForLogging to callTool debug output (security regression) - Remove unused persona/version from Scope3McpClientConfig - Use ListToolsResult from MCP SDK instead of lossy inline type - Validate and trim apiKey (reject whitespace-only) - Update README.md and docs/getting-started.md (remove stale adapter refs) - Add 7 new tests: reconnect, error handling, concurrency --- README.md | 59 +++++++++++++++++++--- docs/getting-started.md | 28 +++++++++-- src/__tests__/mcp-client.test.ts | 66 +++++++++++++++++++++++++ src/index.ts | 7 ++- src/mcp-client.ts | 85 ++++++++++++++++++-------------- 5 files changed, 195 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index 6c777a7..fd805e3 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,9 @@ # Scope3 SDK -TypeScript client for the Scope3 Agentic Platform. Supports two personas (buyer, partner) with REST and MCP adapters. +TypeScript client for the Scope3 Agentic Platform. Two entry points for two audiences: + +- **REST consumers** (humans, CLI, programmatic) → `Scope3Client` with typed resource methods +- **MCP consumers** (AI agents) → `Scope3McpClient` — thin connection helper with direct `callTool`/`readResource` ## Installation @@ -24,11 +27,11 @@ Obtain your API key from the Scope3 dashboard: ## Quick Start -The SDK uses a unified `Scope3Client` with a `persona` parameter to determine available resources. +### REST Client (Humans / CLI / Programmatic) -### Buyer Persona +The `Scope3Client` provides typed resource methods and requires a `persona` parameter. -For programmatic advertising -- manage advertisers, bundles, campaigns, and signals. +#### Buyer Persona ```typescript import { Scope3Client } from 'scope3'; @@ -66,9 +69,7 @@ const campaign = await client.campaigns.createDiscovery({ await client.campaigns.execute(campaign.data.id); ``` -### Partner Persona - -For partner and agent management. +#### Partner Persona ```typescript const partnerClient = new Scope3Client({ @@ -87,20 +88,62 @@ const agent = await partnerClient.agents.register({ }); ``` +### MCP Client (AI Agents) + +The `Scope3McpClient` is a thin connection helper for AI agents. It wires up auth and the MCP URL, then exposes `callTool()`, `readResource()`, and `listTools()` as direct passthroughs. The MCP server handles routing and validation — no typed resource wrappers needed. + +```typescript +import { Scope3McpClient } from 'scope3'; + +const mcp = new Scope3McpClient({ + apiKey: process.env.SCOPE3_API_KEY!, +}); +await mcp.connect(); + +// Call tools directly — the v2 buyer surface exposes: +// api_call, ask_about_capability, help, health +const result = await mcp.callTool('api_call', { + method: 'GET', + path: '/api/v2/buyer/advertisers', +}); + +// Ask what the API can do +const capabilities = await mcp.callTool('ask_about_capability', { + question: 'How do I create a campaign?', +}); + +// List available tools +const tools = await mcp.listTools(); + +await mcp.disconnect(); +``` + ## Configuration +### Scope3Client (REST) + ```typescript const client = new Scope3Client({ apiKey: 'your-api-key', // Required: Bearer token persona: 'buyer', // Required: 'buyer' | 'partner' environment: 'production', // Optional: 'production' (default) | 'staging' baseUrl: 'https://custom.com', // Optional: overrides environment - adapter: 'rest', // Optional: 'rest' (default) | 'mcp' timeout: 30000, // Optional: request timeout in ms debug: false, // Optional: enable debug logging }); ``` +### Scope3McpClient (MCP) + +```typescript +const mcp = new Scope3McpClient({ + apiKey: 'your-api-key', // Required: Bearer token + environment: 'production', // Optional: 'production' (default) | 'staging' + baseUrl: 'https://custom.com', // Optional: overrides environment + debug: false, // Optional: enable debug logging +}); +``` + ## CLI ```bash diff --git a/docs/getting-started.md b/docs/getting-started.md index 6766099..cc77838 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -1,6 +1,6 @@ # Getting Started with the Scope3 SDK -The `scope3` npm package provides a TypeScript/JavaScript client for the Scope3 Agentic Platform. It supports REST and MCP (Model Context Protocol) adapters, and organizes functionality around two personas: buyer and partner. +The `scope3` npm package provides a TypeScript/JavaScript client for the Scope3 Agentic Platform. It provides two clients -- `Scope3Client` for REST consumers and `Scope3McpClient` for AI agents using MCP -- and organizes functionality around two personas: buyer and partner. ## Installation @@ -41,7 +41,7 @@ const client = new Scope3Client({ ## Configuration Options -The full configuration interface: +### Scope3Client (REST) ```typescript interface Scope3ClientConfig { @@ -60,9 +60,6 @@ interface Scope3ClientConfig { /** Custom base URL. Overrides the environment setting. */ baseUrl?: string; - /** Adapter type: 'rest' for HTTP, 'mcp' for AI agents. Default: 'rest'. */ - adapter?: 'rest' | 'mcp'; - /** Request timeout in milliseconds. Default: 30000. */ timeout?: number; @@ -71,6 +68,27 @@ interface Scope3ClientConfig { } ``` +### Scope3McpClient (AI Agents) + +For AI agents using MCP, use `Scope3McpClient` instead. It connects to the MCP server and gives you direct access to `callTool()`, `readResource()`, and `listTools()`. + +```typescript +import { Scope3McpClient } from 'scope3'; + +const mcp = new Scope3McpClient({ + apiKey: process.env.SCOPE3_API_KEY!, + // environment?: 'production' | 'staging' + // baseUrl?: string + // debug?: boolean +}); +await mcp.connect(); + +const result = await mcp.callTool('api_call', { + method: 'GET', + path: '/api/v2/buyer/advertisers', +}); +``` + ## Environment Setup The SDK targets two environments: diff --git a/src/__tests__/mcp-client.test.ts b/src/__tests__/mcp-client.test.ts index 2ef58ef..9f8ba68 100644 --- a/src/__tests__/mcp-client.test.ts +++ b/src/__tests__/mcp-client.test.ts @@ -43,6 +43,10 @@ describe('Scope3McpClient', () => { expect(() => new Scope3McpClient({ apiKey: '' } as any)).toThrow('apiKey is required'); }); + it('should reject whitespace-only apiKey', () => { + expect(() => new Scope3McpClient({ apiKey: ' ' })).toThrow('apiKey is required'); + }); + it('should default to production URL', () => { const client = new Scope3McpClient({ apiKey: 'test-key' }); expect(client.baseUrl).toBe('https://api.agentic.scope3.com'); @@ -99,6 +103,12 @@ describe('Scope3McpClient', () => { await client.connect(); expect(client.isConnected).toBe(true); }); + + it('should deduplicate concurrent connect calls', async () => { + const client = new Scope3McpClient({ apiKey: 'test-key' }); + await Promise.all([client.connect(), client.connect(), client.connect()]); + expect(mockConnect).toHaveBeenCalledTimes(1); + }); }); describe('disconnect', () => { @@ -115,6 +125,44 @@ describe('Scope3McpClient', () => { await client.disconnect(); expect(mockClose).not.toHaveBeenCalled(); }); + + it('should handle disconnect errors gracefully', async () => { + const client = new Scope3McpClient({ apiKey: 'test-key' }); + await client.connect(); + mockClose.mockRejectedValueOnce(new Error('close failed')); + await client.disconnect(); + // Should still mark as disconnected even on error + expect(client.isConnected).toBe(false); + }); + }); + + describe('reconnect lifecycle', () => { + it('should reconnect after disconnect via callTool auto-connect', async () => { + const client = new Scope3McpClient({ apiKey: 'test-key' }); + mockCallTool.mockResolvedValue({ content: [{ type: 'text', text: 'ok' }] }); + + // Connect then disconnect + await client.connect(); + expect(mockConnect).toHaveBeenCalledTimes(1); + await client.disconnect(); + expect(client.isConnected).toBe(false); + + // callTool should auto-reconnect + await client.callTool('health'); + expect(mockConnect).toHaveBeenCalledTimes(2); + expect(client.isConnected).toBe(true); + }); + + it('should reconnect after disconnect via explicit connect', async () => { + const client = new Scope3McpClient({ apiKey: 'test-key' }); + + await client.connect(); + await client.disconnect(); + await client.connect(); + + expect(mockConnect).toHaveBeenCalledTimes(2); + expect(client.isConnected).toBe(true); + }); }); describe('callTool', () => { @@ -171,6 +219,24 @@ describe('Scope3McpClient', () => { expect(result).toEqual(expected); }); + + it('should propagate callTool errors', async () => { + const client = new Scope3McpClient({ apiKey: 'test-key' }); + mockCallTool.mockRejectedValue(new Error('MCP tool error')); + + await expect( + client.callTool('api_call', { method: 'GET', path: '/api/v2/buyer/advertisers' }) + ).rejects.toThrow('MCP tool error'); + }); + + it('should deduplicate concurrent auto-connect from parallel callTool', async () => { + const client = new Scope3McpClient({ apiKey: 'test-key' }); + mockCallTool.mockResolvedValue({ content: [] }); + + await Promise.all([client.callTool('health'), client.callTool('health')]); + + expect(mockConnect).toHaveBeenCalledTimes(1); + }); }); describe('readResource', () => { diff --git a/src/index.ts b/src/index.ts index 17bd091..13a8481 100644 --- a/src/index.ts +++ b/src/index.ts @@ -33,7 +33,12 @@ export { Scope3Client } from './client'; // MCP client — thin connection helper for AI agents export { Scope3McpClient } from './mcp-client'; -export type { Scope3McpClientConfig, CallToolResult, ReadResourceResult } from './mcp-client'; +export type { + Scope3McpClientConfig, + CallToolResult, + ReadResourceResult, + ListToolsResult, +} from './mcp-client'; // ── REST Adapter (for advanced use) ──────────────────────────────── diff --git a/src/mcp-client.ts b/src/mcp-client.ts index 805566d..107ab70 100644 --- a/src/mcp-client.ts +++ b/src/mcp-client.ts @@ -37,23 +37,23 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; -import type { CallToolResult, ReadResourceResult } from '@modelcontextprotocol/sdk/types.js'; -import type { ApiVersion, Persona, Environment } from './types'; -import { Scope3ApiError, getDefaultBaseUrl } from './adapters/base'; +import type { + CallToolResult, + ReadResourceResult, + ListToolsResult, +} from '@modelcontextprotocol/sdk/types.js'; +import type { Environment } from './types'; +import { Scope3ApiError, getDefaultBaseUrl, sanitizeForLogging } from './adapters/base'; import { logger } from './utils/logger'; // Re-export MCP types for consumers -export type { CallToolResult, ReadResourceResult }; +export type { CallToolResult, ReadResourceResult, ListToolsResult }; const SDK_VERSION = '2.1.0'; export interface Scope3McpClientConfig { /** API key (Bearer token) for authentication */ apiKey: string; - /** API persona — determines which MCP surface to connect to */ - persona?: Persona; - /** API version (default: 'v2') */ - version?: ApiVersion; /** Environment (default: 'production') */ environment?: Environment; /** Custom base URL (overrides environment) */ @@ -72,34 +72,23 @@ export interface Scope3McpClientConfig { export class Scope3McpClient { readonly baseUrl: string; - private readonly mcpClient: Client; - private readonly transport: StreamableHTTPClientTransport; + private mcpClient: Client | null = null; + private transport: StreamableHTTPClientTransport | null = null; private connected = false; private connectPromise: Promise | null = null; private readonly debugMode: boolean; + private readonly apiKey: string; constructor(config: Scope3McpClientConfig) { - if (!config.apiKey) { + const trimmedKey = config.apiKey?.trim(); + if (!trimmedKey) { throw new Error('apiKey is required'); } + this.apiKey = trimmedKey; this.debugMode = config.debug ?? false; this.baseUrl = config.baseUrl?.replace(/\/$/, '') ?? getDefaultBaseUrl(config.environment); - this.mcpClient = new Client( - { name: 'scope3-sdk', version: SDK_VERSION }, - { capabilities: { tools: {} } } - ); - - // Build MCP endpoint URL — the server routes based on auth token - this.transport = new StreamableHTTPClientTransport(new URL(`${this.baseUrl}/mcp`), { - requestInit: { - headers: { - Authorization: `Bearer ${config.apiKey}`, - }, - }, - }); - if (this.debugMode) { logger.setDebug(true); logger.debug('Scope3McpClient initialized', { baseUrl: this.baseUrl }); @@ -119,6 +108,19 @@ export class Scope3McpClient { private async doConnect(): Promise { try { + this.mcpClient = new Client( + { name: 'scope3-sdk', version: SDK_VERSION }, + { capabilities: { tools: {} } } + ); + + this.transport = new StreamableHTTPClientTransport(new URL(`${this.baseUrl}/mcp`), { + requestInit: { + headers: { + Authorization: `Bearer ${this.apiKey}`, + }, + }, + }); + await this.mcpClient.connect(this.transport); this.connected = true; if (this.debugMode) { @@ -126,6 +128,8 @@ export class Scope3McpClient { } } catch (error) { this.connectPromise = null; + this.mcpClient = null; + this.transport = null; throw new Scope3ApiError( 0, `Failed to connect to MCP server: ${error instanceof Error ? error.message : 'Unknown error'}` @@ -139,17 +143,28 @@ export class Scope3McpClient { async disconnect(): Promise { if (!this.connected) return; try { - await this.mcpClient.close(); - await this.transport.close(); + await this.mcpClient?.close(); + await this.transport?.close(); + } catch (error) { + logger.error('Error disconnecting from MCP', error); + } finally { this.connected = false; + this.connectPromise = null; + this.mcpClient = null; + this.transport = null; if (this.debugMode) { logger.debug('MCP disconnected'); } - } catch (error) { - logger.error('Error disconnecting from MCP', error); } } + private getClient(): Client { + if (!this.mcpClient) { + throw new Scope3ApiError(0, 'MCP client is not connected'); + } + return this.mcpClient; + } + /** * Call an MCP tool directly. Auto-connects on first call. * @@ -159,10 +174,10 @@ export class Scope3McpClient { if (!this.connected) await this.connect(); if (this.debugMode) { - logger.debug('callTool', { name, args }); + logger.debug('callTool', { name, args: sanitizeForLogging(args) }); } - const result = await this.mcpClient.callTool({ + const result = await this.getClient().callTool({ name, arguments: args, }); @@ -180,17 +195,15 @@ export class Scope3McpClient { logger.debug('readResource', { uri }); } - return this.mcpClient.readResource({ uri }); + return this.getClient().readResource({ uri }); } /** * List all available MCP tools. Auto-connects on first call. */ - async listTools(): Promise<{ - tools: Array<{ name: string; description?: string; inputSchema?: unknown }>; - }> { + async listTools(): Promise { if (!this.connected) await this.connect(); - return this.mcpClient.listTools(); + return this.getClient().listTools(); } /** Whether the client is currently connected */ From 876db4a4e6a5d670c2107b8fd1971505735ca064 Mon Sep 17 00:00:00 2001 From: Emma Mulitz Date: Mon, 30 Mar 2026 22:56:58 -0400 Subject: [PATCH 3/6] fix: source bug fixes, path traversal prevention, and comprehensive test coverage - Fix reporting validation bug (discarded return value) - Add apiKey trimming and exhaustive persona switch in client - Block path traversal (..) in validateResourceId - Fix empty path in Zod error formatting - Add 9 resource test suites (agents, bundles, campaigns, etc.) - Expand base adapter tests (validateResourceId, sanitizeForLogging) - Expand REST adapter, client, and webhook-server tests - 364 tests across 24 suites, all passing --- README.md | 4 + package-lock.json | 16 +- src/__tests__/adapters/base.test.ts | 158 ++++ src/__tests__/adapters/rest.test.ts | 32 + src/__tests__/client.test.ts | 243 ++++++ src/__tests__/resources/agents.test.ts | 142 ++++ src/__tests__/resources/bundles.test.ts | 116 +++ .../resources/conversion-events.test.ts | 88 +++ src/__tests__/resources/creative-sets.test.ts | 83 +++ src/__tests__/resources/partners.test.ts | 94 +++ src/__tests__/resources/reporting.test.ts | 126 ++++ src/__tests__/resources/sales-agents.test.ts | 171 +++++ src/__tests__/resources/signals.test.ts | 58 ++ src/__tests__/resources/test-cohorts.test.ts | 51 ++ src/__tests__/webhook-server.test.ts | 700 +++++++++++++++++- src/adapters/base.ts | 8 +- src/client.ts | 9 +- src/resources/reporting.ts | 2 +- src/validation.ts | 4 +- 19 files changed, 2060 insertions(+), 45 deletions(-) create mode 100644 src/__tests__/resources/agents.test.ts create mode 100644 src/__tests__/resources/bundles.test.ts create mode 100644 src/__tests__/resources/conversion-events.test.ts create mode 100644 src/__tests__/resources/creative-sets.test.ts create mode 100644 src/__tests__/resources/partners.test.ts create mode 100644 src/__tests__/resources/reporting.test.ts create mode 100644 src/__tests__/resources/sales-agents.test.ts create mode 100644 src/__tests__/resources/signals.test.ts create mode 100644 src/__tests__/resources/test-cohorts.test.ts diff --git a/README.md b/README.md index fd805e3..ec265c4 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,10 @@ const agent = await partnerClient.agents.register({ name: 'My Agent', type: 'SALES', partnerId: 'partner-123', + endpointUrl: 'https://my-agent.example.com/mcp', + protocol: 'MCP', + accountPolicy: ['MARKETPLACE'], + authenticationType: 'API_KEY', }); ``` diff --git a/package-lock.json b/package-lock.json index d3d63de..c381301 100644 --- a/package-lock.json +++ b/package-lock.json @@ -103,7 +103,6 @@ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -168,7 +167,6 @@ "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -2462,7 +2460,6 @@ "integrity": "sha512-hRnu+5qggKDSyWHlnmThnUqg62l29Aj/6vcYgUaSFL9oc7DVjeWEQN3PRgdSc6F8d9QRMWkf36CLMch1Do/+RQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -2587,7 +2584,6 @@ "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/types": "6.21.0", @@ -2781,7 +2777,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2948,7 +2943,6 @@ "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", @@ -3193,7 +3187,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.9", "caniuse-lite": "^1.0.30001746", @@ -4013,7 +4006,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -4295,7 +4287,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -5311,7 +5302,6 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -6877,8 +6867,7 @@ "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/openapi-zod-client": { "version": "1.18.3", @@ -8423,7 +8412,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -8538,7 +8526,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -8869,7 +8856,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/src/__tests__/adapters/base.test.ts b/src/__tests__/adapters/base.test.ts index dffd8bb..3ee7a28 100644 --- a/src/__tests__/adapters/base.test.ts +++ b/src/__tests__/adapters/base.test.ts @@ -8,6 +8,8 @@ import { resolveBaseUrl, resolveVersion, resolvePersona, + validateResourceId, + sanitizeForLogging, } from '../../adapters/base'; describe('Scope3ApiError', () => { @@ -113,3 +115,159 @@ describe('resolvePersona', () => { expect(resolvePersona({ apiKey: 'k', persona: 'partner' })).toBe('partner'); }); }); + +describe('validateResourceId', () => { + it('should throw Scope3ApiError with status 400 for empty string', () => { + expect(() => validateResourceId('')).toThrow(Scope3ApiError); + try { + validateResourceId(''); + } catch (e) { + expect(e).toBeInstanceOf(Scope3ApiError); + expect((e as Scope3ApiError).status).toBe(400); + } + }); + + it('should throw for string with /', () => { + expect(() => validateResourceId('foo/bar')).toThrow(Scope3ApiError); + }); + + it('should throw for string with \\', () => { + expect(() => validateResourceId('foo\\bar')).toThrow(Scope3ApiError); + }); + + it('should throw for string with ?', () => { + expect(() => validateResourceId('foo?bar')).toThrow(Scope3ApiError); + }); + + it('should throw for string with #', () => { + expect(() => validateResourceId('foo#bar')).toThrow(Scope3ApiError); + }); + + it('should throw for string with ..', () => { + expect(() => validateResourceId('..')).toThrow(Scope3ApiError); + }); + + it('should return encoded string for normal input', () => { + expect(validateResourceId('abc-123')).toBe('abc-123'); + }); + + it('should URI-encode string with spaces', () => { + expect(validateResourceId('hello world')).toBe('hello%20world'); + }); + + it('should URI-encode string with unicode', () => { + expect(validateResourceId('caf\u00e9')).toBe('caf%C3%A9'); + }); +}); + +describe('sanitizeForLogging', () => { + it('should return null as-is', () => { + expect(sanitizeForLogging(null)).toBeNull(); + }); + + it('should return undefined as-is', () => { + expect(sanitizeForLogging(undefined)).toBeUndefined(); + }); + + it('should return primitives as-is', () => { + expect(sanitizeForLogging('hello')).toBe('hello'); + expect(sanitizeForLogging(42)).toBe(42); + expect(sanitizeForLogging(true)).toBe(true); + }); + + it('should redact keys containing api_key', () => { + expect(sanitizeForLogging({ api_key: 'secret123' })).toEqual({ + api_key: '[REDACTED]', + }); + expect(sanitizeForLogging({ my_api_key: 'secret123' })).toEqual({ + my_api_key: '[REDACTED]', + }); + }); + + it('should redact keys containing token', () => { + expect(sanitizeForLogging({ accessToken: 'abc' })).toEqual({ + accessToken: '[REDACTED]', + }); + }); + + it('should redact keys containing password', () => { + expect(sanitizeForLogging({ password: 'abc' })).toEqual({ + password: '[REDACTED]', + }); + }); + + it('should redact keys containing secret', () => { + expect(sanitizeForLogging({ clientSecret: 'abc' })).toEqual({ + clientSecret: '[REDACTED]', + }); + }); + + it('should redact keys containing authorization', () => { + expect(sanitizeForLogging({ authorization: 'Bearer xyz' })).toEqual({ + authorization: '[REDACTED]', + }); + }); + + it('should NOT redact non-sensitive keys', () => { + expect(sanitizeForLogging({ name: 'Alice', age: 30 })).toEqual({ + name: 'Alice', + age: 30, + }); + }); + + it('should handle nested objects and redact at any depth', () => { + const input = { + user: { + name: 'Alice', + credentials: { + password: 'supersecret', + }, + }, + }; + expect(sanitizeForLogging(input)).toEqual({ + user: { + name: 'Alice', + credentials: { + password: '[REDACTED]', + }, + }, + }); + }); + + it('should handle arrays', () => { + const input = [{ token: 'secret' }, { name: 'safe' }]; + expect(sanitizeForLogging(input)).toEqual([{ token: '[REDACTED]' }, { name: 'safe' }]); + }); + + it('should skip __proto__, constructor, prototype keys', () => { + const obj = Object.create(null); + obj['__proto__'] = 'bad'; + obj['constructor'] = 'bad'; + obj['prototype'] = 'bad'; + obj['name'] = 'good'; + + const result = sanitizeForLogging(obj) as Record; + expect(result).toEqual({ name: 'good' }); + expect(Object.keys(result)).not.toContain('__proto__'); + expect(Object.keys(result)).not.toContain('constructor'); + expect(Object.keys(result)).not.toContain('prototype'); + }); + + it('should stop recursing at depth > 10', () => { + // Build a deeply nested object (12 levels of wrapping) + let nested: Record = { token: 'should-stay' }; + for (let i = 0; i < 12; i++) { + nested = { child: nested }; + } + + const result = sanitizeForLogging(nested) as Record; + + // Walk 12 levels deep to reach the innermost object + let current: unknown = result; + for (let i = 0; i < 12; i++) { + current = (current as Record).child; + } + // At depth > 10, the object is returned as-is without sanitizing + expect((current as Record).token).toBe('should-stay'); + }); +}); diff --git a/src/__tests__/adapters/rest.test.ts b/src/__tests__/adapters/rest.test.ts index 21112e1..bb6d782 100644 --- a/src/__tests__/adapters/rest.test.ts +++ b/src/__tests__/adapters/rest.test.ts @@ -255,6 +255,38 @@ describe('RestAdapter', () => { status: 408, }); }); + + it('should return undefined for 204 No Content response', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 204, + headers: { get: () => null }, + }); + + const adapter = new RestAdapter({ apiKey: 'test-key', persona: 'buyer' }); + const result = await adapter.request('DELETE', '/advertisers/123'); + + expect(result).toBeUndefined(); + }); + + it('should call fetch with PATCH method and body', async () => { + mockFetch.mockResolvedValue({ + ok: true, + headers: { get: (key: string) => (key === 'content-type' ? 'application/json' : null) }, + json: () => Promise.resolve({ id: '123', name: 'Patched' }), + }); + + const adapter = new RestAdapter({ apiKey: 'test-key', persona: 'buyer' }); + await adapter.request('PATCH', '/advertisers/123', { name: 'Patched' }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + method: 'PATCH', + body: JSON.stringify({ name: 'Patched' }), + }) + ); + }); }); describe('connect/disconnect', () => { diff --git a/src/__tests__/client.test.ts b/src/__tests__/client.test.ts index 9618e40..b06c6f2 100644 --- a/src/__tests__/client.test.ts +++ b/src/__tests__/client.test.ts @@ -3,6 +3,20 @@ */ import { Scope3Client } from '../client'; +import { ConversionEventsResource } from '../resources/conversion-events'; +import { CreativeSetsResource } from '../resources/creative-sets'; +import { TestCohortsResource } from '../resources/test-cohorts'; +import { BundleProductsResource } from '../resources/products'; + +jest.mock('../skill', () => ({ + fetchSkillMd: jest.fn(), + parseSkillMd: jest.fn(), +})); + +import { fetchSkillMd, parseSkillMd } from '../skill'; + +const mockFetchSkillMd = fetchSkillMd as jest.Mock; +const mockParseSkillMd = parseSkillMd as jest.Mock; describe('Scope3Client', () => { describe('initialization', () => { @@ -14,6 +28,12 @@ describe('Scope3Client', () => { expect(() => new Scope3Client({} as any)).toThrow('apiKey is required'); }); + it('should throw for whitespace-only apiKey', () => { + expect(() => new Scope3Client({ apiKey: ' ', persona: 'buyer' })).toThrow( + 'apiKey is required' + ); + }); + it('should require persona', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any expect(() => new Scope3Client({ apiKey: 'test-key' } as any)).toThrow('persona is required'); @@ -186,4 +206,227 @@ describe('Scope3Client', () => { expect(client.debug).toBe(true); }); }); + + // ── getSkill ───────────────────────────────────────────────── + + describe('getSkill', () => { + let client: Scope3Client; + + const fakeParsed = { + name: 'scope3-agentic-buyer', + version: '2.0.0', + description: 'Buyer skill', + apiBase: 'https://api.agentic.scope3.com', + commands: [], + examples: [], + }; + + beforeEach(() => { + mockFetchSkillMd.mockReset(); + mockParseSkillMd.mockReset(); + client = new Scope3Client({ apiKey: 'test-key', persona: 'buyer' }); + }); + + it('should fetch and parse skill.md on first call', async () => { + mockFetchSkillMd.mockResolvedValue('# Skill\nraw markdown'); + mockParseSkillMd.mockReturnValue(fakeParsed); + + const result = await client.getSkill(); + + expect(mockFetchSkillMd).toHaveBeenCalledTimes(1); + expect(mockFetchSkillMd).toHaveBeenCalledWith({ + version: 'v2', + persona: 'buyer', + baseUrl: 'https://api.agentic.scope3.com', + }); + expect(mockParseSkillMd).toHaveBeenCalledTimes(1); + expect(mockParseSkillMd).toHaveBeenCalledWith('# Skill\nraw markdown'); + expect(result).toEqual(fakeParsed); + }); + + it('should cache the result and only fetch once', async () => { + mockFetchSkillMd.mockResolvedValue('markdown'); + mockParseSkillMd.mockReturnValue(fakeParsed); + + const first = await client.getSkill(); + const second = await client.getSkill(); + const third = await client.getSkill(); + + expect(mockFetchSkillMd).toHaveBeenCalledTimes(1); + expect(mockParseSkillMd).toHaveBeenCalledTimes(1); + expect(first).toBe(second); + expect(second).toBe(third); + }); + + it('should return the same promise for concurrent calls', async () => { + mockFetchSkillMd.mockResolvedValue('markdown'); + mockParseSkillMd.mockReturnValue(fakeParsed); + + const [a, b, c] = await Promise.all([ + client.getSkill(), + client.getSkill(), + client.getSkill(), + ]); + + expect(mockFetchSkillMd).toHaveBeenCalledTimes(1); + expect(a).toBe(b); + expect(b).toBe(c); + }); + + it('should clear cache on error so next call retries', async () => { + mockFetchSkillMd.mockRejectedValueOnce(new Error('Network error')); + + await expect(client.getSkill()).rejects.toThrow('Network error'); + + // After error, cache should be cleared — next call should retry + mockFetchSkillMd.mockResolvedValue('recovered markdown'); + mockParseSkillMd.mockReturnValue(fakeParsed); + + const result = await client.getSkill(); + + expect(mockFetchSkillMd).toHaveBeenCalledTimes(2); + expect(result).toEqual(fakeParsed); + }); + + it('should pass correct params for partner persona', async () => { + const partnerClient = new Scope3Client({ apiKey: 'test-key', persona: 'partner' }); + mockFetchSkillMd.mockResolvedValue('markdown'); + mockParseSkillMd.mockReturnValue({ ...fakeParsed, name: 'scope3-agentic-partner' }); + + await partnerClient.getSkill(); + + expect(mockFetchSkillMd).toHaveBeenCalledWith( + expect.objectContaining({ persona: 'partner' }) + ); + }); + + it('should pass correct params for custom version', async () => { + const v1Client = new Scope3Client({ apiKey: 'test-key', persona: 'buyer', version: 'v1' }); + mockFetchSkillMd.mockResolvedValue('markdown'); + mockParseSkillMd.mockReturnValue(fakeParsed); + + await v1Client.getSkill(); + + expect(mockFetchSkillMd).toHaveBeenCalledWith(expect.objectContaining({ version: 'v1' })); + }); + + it('should pass correct params for custom baseUrl', async () => { + const customClient = new Scope3Client({ + apiKey: 'test-key', + persona: 'buyer', + baseUrl: 'https://custom.api.com', + }); + mockFetchSkillMd.mockResolvedValue('markdown'); + mockParseSkillMd.mockReturnValue(fakeParsed); + + await customClient.getSkill(); + + expect(mockFetchSkillMd).toHaveBeenCalledWith( + expect.objectContaining({ baseUrl: 'https://custom.api.com' }) + ); + }); + }); + + // ── Sub-resource access ────────────────────────────────────── + + describe('sub-resource access', () => { + describe('advertisers sub-resources', () => { + let client: Scope3Client; + + beforeEach(() => { + client = new Scope3Client({ apiKey: 'test-key', persona: 'buyer' }); + }); + + it('conversionEvents() returns a ConversionEventsResource', () => { + const resource = client.advertisers.conversionEvents('adv-123'); + expect(resource).toBeInstanceOf(ConversionEventsResource); + }); + + it('conversionEvents() has list, get, create, update methods', () => { + const resource = client.advertisers.conversionEvents('adv-123'); + expect(typeof resource.list).toBe('function'); + expect(typeof resource.get).toBe('function'); + expect(typeof resource.create).toBe('function'); + expect(typeof resource.update).toBe('function'); + }); + + it('creativeSets() returns a CreativeSetsResource', () => { + const resource = client.advertisers.creativeSets('adv-456'); + expect(resource).toBeInstanceOf(CreativeSetsResource); + }); + + it('creativeSets() has list, create, addAsset, removeAsset methods', () => { + const resource = client.advertisers.creativeSets('adv-456'); + expect(typeof resource.list).toBe('function'); + expect(typeof resource.create).toBe('function'); + expect(typeof resource.addAsset).toBe('function'); + expect(typeof resource.removeAsset).toBe('function'); + }); + + it('testCohorts() returns a TestCohortsResource', () => { + const resource = client.advertisers.testCohorts('adv-789'); + expect(resource).toBeInstanceOf(TestCohortsResource); + }); + + it('testCohorts() has list and create methods', () => { + const resource = client.advertisers.testCohorts('adv-789'); + expect(typeof resource.list).toBe('function'); + expect(typeof resource.create).toBe('function'); + }); + + it('returns a new resource instance each call (not cached)', () => { + const a = client.advertisers.conversionEvents('adv-123'); + const b = client.advertisers.conversionEvents('adv-123'); + expect(a).not.toBe(b); + }); + + it('returns different resources for different advertiser IDs', () => { + const a = client.advertisers.conversionEvents('adv-1'); + const b = client.advertisers.conversionEvents('adv-2'); + expect(a).not.toBe(b); + }); + }); + + describe('bundles sub-resources', () => { + let client: Scope3Client; + + beforeEach(() => { + client = new Scope3Client({ apiKey: 'test-key', persona: 'buyer' }); + }); + + it('products() returns a BundleProductsResource', () => { + const resource = client.bundles.products('bundle-123'); + expect(resource).toBeInstanceOf(BundleProductsResource); + }); + + it('products() has list, add, remove methods', () => { + const resource = client.bundles.products('bundle-123'); + expect(typeof resource.list).toBe('function'); + expect(typeof resource.add).toBe('function'); + expect(typeof resource.remove).toBe('function'); + }); + + it('returns a new resource instance each call', () => { + const a = client.bundles.products('bundle-123'); + const b = client.bundles.products('bundle-123'); + expect(a).not.toBe(b); + }); + }); + + describe('partner persona cannot access buyer sub-resources', () => { + it('should throw when accessing advertisers sub-resources', () => { + const client = new Scope3Client({ apiKey: 'test-key', persona: 'partner' }); + expect(() => client.advertisers.conversionEvents('adv-1')).toThrow( + 'advertisers is only available with the buyer persona' + ); + }); + + it('should throw when accessing bundles sub-resources', () => { + const client = new Scope3Client({ apiKey: 'test-key', persona: 'partner' }); + expect(() => client.bundles.products('bundle-1')).toThrow( + 'bundles is only available with the buyer persona' + ); + }); + }); + }); }); diff --git a/src/__tests__/resources/agents.test.ts b/src/__tests__/resources/agents.test.ts new file mode 100644 index 0000000..755339a --- /dev/null +++ b/src/__tests__/resources/agents.test.ts @@ -0,0 +1,142 @@ +/** + * Tests for AgentsResource + */ + +import { AgentsResource } from '../../resources/agents'; +import type { BaseAdapter } from '../../adapters/base'; + +describe('AgentsResource', () => { + let mockAdapter: jest.Mocked; + let resource: AgentsResource; + + beforeEach(() => { + mockAdapter = { + baseUrl: 'https://api.test.com', + version: 'v2', + persona: 'partner' as const, + debug: false, + validate: false, + request: jest.fn(), + connect: jest.fn(), + disconnect: jest.fn(), + }; + resource = new AgentsResource(mockAdapter); + }); + + describe('list', () => { + it('should call adapter with correct path and no params', async () => { + mockAdapter.request.mockResolvedValue({ items: [] }); + + await resource.list(); + + expect(mockAdapter.request).toHaveBeenCalledWith('GET', '/agents', undefined, { + params: { + type: undefined, + status: undefined, + relationship: undefined, + }, + }); + }); + + it('should pass filter params when provided', async () => { + mockAdapter.request.mockResolvedValue({ items: [] }); + + await resource.list({ + type: 'SALES', + status: 'ACTIVE', + relationship: 'CONNECTED', + }); + + expect(mockAdapter.request).toHaveBeenCalledWith('GET', '/agents', undefined, { + params: { + type: 'SALES', + status: 'ACTIVE', + relationship: 'CONNECTED', + }, + }); + }); + }); + + describe('get', () => { + it('should call adapter with correct path', async () => { + mockAdapter.request.mockResolvedValue({ id: 'agent-1', name: 'Test Agent' }); + + await resource.get('agent-1'); + + expect(mockAdapter.request).toHaveBeenCalledWith('GET', '/agents/agent-1'); + }); + }); + + describe('register', () => { + it('should call adapter with correct path and body', async () => { + const input = { + partnerId: 'p-1', + type: 'SALES' as const, + name: 'New Agent', + endpointUrl: 'https://agent.example.com', + protocol: 'MCP' as const, + accountPolicy: ['READ'], + authenticationType: 'API_KEY' as const, + }; + mockAdapter.request.mockResolvedValue({ id: 'agent-1', name: 'New Agent' }); + + await resource.register(input); + + expect(mockAdapter.request).toHaveBeenCalledWith('POST', '/agents', input); + }); + }); + + describe('update', () => { + it('should call adapter with correct path and body', async () => { + const input = { name: 'Updated Agent' }; + mockAdapter.request.mockResolvedValue({ id: 'agent-1', name: 'Updated Agent' }); + + await resource.update('agent-1', input); + + expect(mockAdapter.request).toHaveBeenCalledWith('PATCH', '/agents/agent-1', input); + }); + }); + + describe('authorizeOAuth', () => { + it('should call adapter with correct path', async () => { + mockAdapter.request.mockResolvedValue({ authorizationUrl: 'https://oauth.example.com' }); + + await resource.authorizeOAuth('agent-1'); + + expect(mockAdapter.request).toHaveBeenCalledWith( + 'POST', + '/agents/agent-1/oauth/authorize', + {} + ); + }); + }); + + describe('authorizeAccountOAuth', () => { + it('should call adapter with correct path', async () => { + mockAdapter.request.mockResolvedValue({ authorizationUrl: 'https://oauth.example.com' }); + + await resource.authorizeAccountOAuth('agent-1'); + + expect(mockAdapter.request).toHaveBeenCalledWith( + 'POST', + '/agents/agent-1/accounts/oauth/authorize', + {} + ); + }); + }); + + describe('exchangeOAuthCode', () => { + it('should call adapter with correct path and body', async () => { + const input = { code: 'auth-code-123', state: 'state-456' }; + mockAdapter.request.mockResolvedValue({ success: true }); + + await resource.exchangeOAuthCode('agent-1', input); + + expect(mockAdapter.request).toHaveBeenCalledWith( + 'POST', + '/agents/agent-1/oauth/callback', + input + ); + }); + }); +}); diff --git a/src/__tests__/resources/bundles.test.ts b/src/__tests__/resources/bundles.test.ts new file mode 100644 index 0000000..b45e38b --- /dev/null +++ b/src/__tests__/resources/bundles.test.ts @@ -0,0 +1,116 @@ +/** + * Tests for BundlesResource + */ + +import { BundlesResource } from '../../resources/bundles'; +import { BundleProductsResource } from '../../resources/products'; +import type { BaseAdapter } from '../../adapters/base'; + +function createMockAdapter(overrides?: Partial): jest.Mocked { + return { + baseUrl: 'https://api.test.com', + version: 'v2', + persona: 'buyer' as const, + debug: false, + validate: false, + request: jest.fn(), + connect: jest.fn(), + disconnect: jest.fn(), + ...overrides, + } as jest.Mocked; +} + +describe('BundlesResource', () => { + let mockAdapter: jest.Mocked; + let resource: BundlesResource; + + beforeEach(() => { + mockAdapter = createMockAdapter(); + resource = new BundlesResource(mockAdapter); + }); + + describe('create', () => { + it('should call adapter with correct path and body', async () => { + const input = { advertiserId: 'adv-1', channels: ['display'] }; + mockAdapter.request.mockResolvedValue({ bundleId: 'b-1' }); + + await resource.create(input); + + expect(mockAdapter.request).toHaveBeenCalledWith('POST', '/bundles', input); + }); + }); + + describe('discoverProducts', () => { + it('should call adapter with correct path and query params', async () => { + mockAdapter.request.mockResolvedValue({ groups: [] }); + + await resource.discoverProducts('bundle-123', { + groupLimit: 5, + groupOffset: 0, + productsPerGroup: 10, + productOffset: 0, + publisherDomain: 'example.com', + salesAgentIds: 'sa-1', + salesAgentNames: 'Agent One', + }); + + expect(mockAdapter.request).toHaveBeenCalledWith( + 'GET', + '/bundles/bundle-123/discover-products', + undefined, + { + params: { + groupLimit: 5, + groupOffset: 0, + productsPerGroup: 10, + productOffset: 0, + publisherDomain: 'example.com', + salesAgentIds: 'sa-1', + salesAgentNames: 'Agent One', + }, + } + ); + }); + + it('should call adapter with no params when none provided', async () => { + mockAdapter.request.mockResolvedValue({ groups: [] }); + + await resource.discoverProducts('bundle-456'); + + expect(mockAdapter.request).toHaveBeenCalledWith( + 'GET', + '/bundles/bundle-456/discover-products', + undefined, + { + params: { + groupLimit: undefined, + groupOffset: undefined, + productsPerGroup: undefined, + productOffset: undefined, + publisherDomain: undefined, + salesAgentIds: undefined, + salesAgentNames: undefined, + }, + } + ); + }); + }); + + describe('browseProducts', () => { + it('should call adapter with correct path and body', async () => { + const input = { advertiserId: 'adv-1', channels: ['display'] }; + mockAdapter.request.mockResolvedValue({ groups: [], bundleId: 'b-auto' }); + + await resource.browseProducts(input); + + expect(mockAdapter.request).toHaveBeenCalledWith('POST', '/bundles/discover-products', input); + }); + }); + + describe('products', () => { + it('should return a BundleProductsResource instance', () => { + const productsResource = resource.products('bundle-789'); + expect(productsResource).toBeInstanceOf(BundleProductsResource); + }); + }); +}); diff --git a/src/__tests__/resources/conversion-events.test.ts b/src/__tests__/resources/conversion-events.test.ts new file mode 100644 index 0000000..ec065ba --- /dev/null +++ b/src/__tests__/resources/conversion-events.test.ts @@ -0,0 +1,88 @@ +/** + * Tests for ConversionEventsResource + */ + +import { ConversionEventsResource } from '../../resources/conversion-events'; +import type { BaseAdapter } from '../../adapters/base'; + +describe('ConversionEventsResource', () => { + let mockAdapter: jest.Mocked; + let resource: ConversionEventsResource; + const advertiserId = 'adv-123'; + + beforeEach(() => { + mockAdapter = { + baseUrl: 'https://api.test.com', + version: 'v2', + persona: 'buyer' as const, + debug: false, + validate: false, + request: jest.fn(), + connect: jest.fn(), + disconnect: jest.fn(), + }; + resource = new ConversionEventsResource(mockAdapter, advertiserId); + }); + + describe('constructor', () => { + it('should accept adapter and advertiserId', () => { + expect(resource).toBeDefined(); + }); + }); + + describe('list', () => { + it('should call adapter with correct path', async () => { + mockAdapter.request.mockResolvedValue({ items: [] }); + + await resource.list(); + + expect(mockAdapter.request).toHaveBeenCalledWith( + 'GET', + '/advertisers/adv-123/conversion-events' + ); + }); + }); + + describe('get', () => { + it('should call adapter with correct path', async () => { + mockAdapter.request.mockResolvedValue({ id: 'evt-1' }); + + await resource.get('evt-1'); + + expect(mockAdapter.request).toHaveBeenCalledWith( + 'GET', + '/advertisers/adv-123/conversion-events/evt-1' + ); + }); + }); + + describe('create', () => { + it('should call adapter with correct path and body', async () => { + const input = { name: 'Purchase', type: 'PURCHASE' as const }; + mockAdapter.request.mockResolvedValue({ id: 'evt-1', name: 'Purchase' }); + + await resource.create(input); + + expect(mockAdapter.request).toHaveBeenCalledWith( + 'POST', + '/advertisers/adv-123/conversion-events', + input + ); + }); + }); + + describe('update', () => { + it('should call adapter with correct path and body', async () => { + const input = { name: 'Updated Purchase' }; + mockAdapter.request.mockResolvedValue({ id: 'evt-1', name: 'Updated Purchase' }); + + await resource.update('evt-1', input); + + expect(mockAdapter.request).toHaveBeenCalledWith( + 'PUT', + '/advertisers/adv-123/conversion-events/evt-1', + input + ); + }); + }); +}); diff --git a/src/__tests__/resources/creative-sets.test.ts b/src/__tests__/resources/creative-sets.test.ts new file mode 100644 index 0000000..b121c53 --- /dev/null +++ b/src/__tests__/resources/creative-sets.test.ts @@ -0,0 +1,83 @@ +/** + * Tests for CreativeSetsResource + */ + +import { CreativeSetsResource } from '../../resources/creative-sets'; +import type { BaseAdapter } from '../../adapters/base'; + +describe('CreativeSetsResource', () => { + let mockAdapter: jest.Mocked; + let resource: CreativeSetsResource; + const advertiserId = 'adv-123'; + + beforeEach(() => { + mockAdapter = { + baseUrl: 'https://api.test.com', + version: 'v2', + persona: 'buyer' as const, + debug: false, + validate: false, + request: jest.fn(), + connect: jest.fn(), + disconnect: jest.fn(), + }; + resource = new CreativeSetsResource(mockAdapter, advertiserId); + }); + + describe('list', () => { + it('should call adapter with correct path', async () => { + mockAdapter.request.mockResolvedValue({ items: [] }); + + await resource.list(); + + expect(mockAdapter.request).toHaveBeenCalledWith('GET', '/advertisers/adv-123/creative-sets'); + }); + }); + + describe('create', () => { + it('should call adapter with correct path and body', async () => { + const input = { name: 'Holiday Set', type: 'DISPLAY' }; + mockAdapter.request.mockResolvedValue({ id: 'cs-1', name: 'Holiday Set' }); + + await resource.create(input); + + expect(mockAdapter.request).toHaveBeenCalledWith( + 'POST', + '/advertisers/adv-123/creative-sets', + input + ); + }); + }); + + describe('addAsset', () => { + it('should call adapter with correct path and body', async () => { + const input = { + assetUrl: 'https://cdn.example.com/image.png', + name: 'Banner', + type: 'IMAGE', + }; + mockAdapter.request.mockResolvedValue({ id: 'asset-1' }); + + await resource.addAsset('cs-1', input); + + expect(mockAdapter.request).toHaveBeenCalledWith( + 'POST', + '/advertisers/adv-123/creative-sets/cs-1/assets', + input + ); + }); + }); + + describe('removeAsset', () => { + it('should call adapter with correct path', async () => { + mockAdapter.request.mockResolvedValue(undefined); + + await resource.removeAsset('cs-1', 'asset-1'); + + expect(mockAdapter.request).toHaveBeenCalledWith( + 'DELETE', + '/advertisers/adv-123/creative-sets/cs-1/assets/asset-1' + ); + }); + }); +}); diff --git a/src/__tests__/resources/partners.test.ts b/src/__tests__/resources/partners.test.ts new file mode 100644 index 0000000..08585b7 --- /dev/null +++ b/src/__tests__/resources/partners.test.ts @@ -0,0 +1,94 @@ +/** + * Tests for PartnersResource + */ + +import { PartnersResource } from '../../resources/partners'; +import type { BaseAdapter } from '../../adapters/base'; + +describe('PartnersResource', () => { + let mockAdapter: jest.Mocked; + let resource: PartnersResource; + + beforeEach(() => { + mockAdapter = { + baseUrl: 'https://api.test.com', + version: 'v2', + persona: 'partner' as const, + debug: false, + validate: false, + request: jest.fn(), + connect: jest.fn(), + disconnect: jest.fn(), + }; + resource = new PartnersResource(mockAdapter); + }); + + describe('list', () => { + it('should call adapter with correct path and no params', async () => { + mockAdapter.request.mockResolvedValue({ items: [] }); + + await resource.list(); + + expect(mockAdapter.request).toHaveBeenCalledWith('GET', '/partners', undefined, { + params: { + status: undefined, + name: undefined, + take: undefined, + skip: undefined, + }, + }); + }); + + it('should pass filter params when provided', async () => { + mockAdapter.request.mockResolvedValue({ items: [] }); + + await resource.list({ + status: 'ACTIVE', + name: 'Acme Partner', + take: 10, + skip: 20, + }); + + expect(mockAdapter.request).toHaveBeenCalledWith('GET', '/partners', undefined, { + params: { + status: 'ACTIVE', + name: 'Acme Partner', + take: 10, + skip: 20, + }, + }); + }); + }); + + describe('create', () => { + it('should call adapter with correct path and body', async () => { + const input = { name: 'New Partner', domain: 'partner.com' }; + mockAdapter.request.mockResolvedValue({ id: 'p-1', name: 'New Partner' }); + + await resource.create(input); + + expect(mockAdapter.request).toHaveBeenCalledWith('POST', '/partners', input); + }); + }); + + describe('update', () => { + it('should call adapter with correct path and body', async () => { + const input = { name: 'Updated Partner' }; + mockAdapter.request.mockResolvedValue({ id: 'p-1', name: 'Updated Partner' }); + + await resource.update('p-1', input); + + expect(mockAdapter.request).toHaveBeenCalledWith('PUT', '/partners/p-1', input); + }); + }); + + describe('archive', () => { + it('should call adapter with correct path', async () => { + mockAdapter.request.mockResolvedValue(undefined); + + await resource.archive('p-1'); + + expect(mockAdapter.request).toHaveBeenCalledWith('DELETE', '/partners/p-1'); + }); + }); +}); diff --git a/src/__tests__/resources/reporting.test.ts b/src/__tests__/resources/reporting.test.ts new file mode 100644 index 0000000..9209fe0 --- /dev/null +++ b/src/__tests__/resources/reporting.test.ts @@ -0,0 +1,126 @@ +/** + * Tests for ReportingResource + */ + +import { ReportingResource } from '../../resources/reporting'; +import type { BaseAdapter } from '../../adapters/base'; + +// Mock the validation module +jest.mock('../../validation', () => ({ + shouldValidateResponse: jest.fn(), + validateResponse: jest.fn(), +})); + +// Mock the schemas registry +jest.mock('../../schemas/registry', () => ({ + reportingSchemas: { + response: { parse: jest.fn() }, + }, +})); + +import { shouldValidateResponse, validateResponse } from '../../validation'; +import { reportingSchemas } from '../../schemas/registry'; + +const mockShouldValidate = shouldValidateResponse as jest.Mock; +const mockValidateResponse = validateResponse as jest.Mock; + +function createMockAdapter(overrides?: Partial): jest.Mocked { + return { + baseUrl: 'https://api.test.com', + version: 'v2', + persona: 'buyer' as const, + debug: false, + validate: false, + request: jest.fn(), + connect: jest.fn(), + disconnect: jest.fn(), + ...overrides, + } as jest.Mocked; +} + +describe('ReportingResource', () => { + let mockAdapter: jest.Mocked; + let resource: ReportingResource; + + beforeEach(() => { + mockAdapter = createMockAdapter(); + resource = new ReportingResource(mockAdapter); + jest.clearAllMocks(); + }); + + describe('get', () => { + it('should call adapter with correct path and no params', async () => { + mockAdapter.request.mockResolvedValue({ summary: {} }); + mockShouldValidate.mockReturnValue(false); + + await resource.get(); + + expect(mockAdapter.request).toHaveBeenCalledWith('GET', '/reporting/metrics', undefined, { + params: { + view: undefined, + days: undefined, + startDate: undefined, + endDate: undefined, + advertiserId: undefined, + campaignId: undefined, + demo: undefined, + }, + }); + }); + + it('should pass all params when provided', async () => { + mockAdapter.request.mockResolvedValue({ summary: {} }); + mockShouldValidate.mockReturnValue(false); + + await resource.get({ + view: 'summary', + days: 30, + startDate: '2024-01-01', + endDate: '2024-01-31', + advertiserId: 'adv-1', + campaignId: 'camp-1', + demo: true, + }); + + expect(mockAdapter.request).toHaveBeenCalledWith('GET', '/reporting/metrics', undefined, { + params: { + view: 'summary', + days: 30, + startDate: '2024-01-01', + endDate: '2024-01-31', + advertiserId: 'adv-1', + campaignId: 'camp-1', + demo: true, + }, + }); + }); + + it('should validate response when validation is enabled', async () => { + const validatingAdapter = createMockAdapter({ validate: true }); + const validatingResource = new ReportingResource(validatingAdapter); + + const mockResult = { summary: { impressions: 100 } }; + validatingAdapter.request.mockResolvedValue(mockResult); + mockShouldValidate.mockReturnValue(true); + mockValidateResponse.mockReturnValue(mockResult); + + const result = await validatingResource.get(); + + expect(mockShouldValidate).toHaveBeenCalledWith(true); + expect(mockValidateResponse).toHaveBeenCalledWith(reportingSchemas.response, mockResult); + expect(result).toBe(mockResult); + }); + + it('should skip validation when validation is disabled', async () => { + const mockResult = { summary: {} }; + mockAdapter.request.mockResolvedValue(mockResult); + mockShouldValidate.mockReturnValue(false); + + const result = await resource.get(); + + expect(mockShouldValidate).toHaveBeenCalledWith(false); + expect(mockValidateResponse).not.toHaveBeenCalled(); + expect(result).toBe(mockResult); + }); + }); +}); diff --git a/src/__tests__/resources/sales-agents.test.ts b/src/__tests__/resources/sales-agents.test.ts new file mode 100644 index 0000000..826d03c --- /dev/null +++ b/src/__tests__/resources/sales-agents.test.ts @@ -0,0 +1,171 @@ +/** + * Tests for SalesAgentsResource + */ + +import { SalesAgentsResource } from '../../resources/sales-agents'; +import type { BaseAdapter } from '../../adapters/base'; + +// Mock the validation module +jest.mock('../../validation', () => ({ + shouldValidateInput: jest.fn(), + shouldValidateResponse: jest.fn(), + validateInput: jest.fn(), + validateResponse: jest.fn(), +})); + +// Mock the schemas registry +jest.mock('../../schemas/registry', () => ({ + salesAgentSchemas: { + listResponse: { parse: jest.fn() }, + registerAccountInput: { parse: jest.fn() }, + accountResponse: { parse: jest.fn() }, + }, +})); + +import { + shouldValidateInput, + shouldValidateResponse, + validateInput, + validateResponse, +} from '../../validation'; +import { salesAgentSchemas } from '../../schemas/registry'; + +const mockShouldValidateInput = shouldValidateInput as jest.Mock; +const mockShouldValidateResponse = shouldValidateResponse as jest.Mock; +const mockValidateInput = validateInput as jest.Mock; +const mockValidateResponse = validateResponse as jest.Mock; + +function createMockAdapter(overrides?: Partial): jest.Mocked { + return { + baseUrl: 'https://api.test.com', + version: 'v2', + persona: 'buyer' as const, + debug: false, + validate: false, + request: jest.fn(), + connect: jest.fn(), + disconnect: jest.fn(), + ...overrides, + } as jest.Mocked; +} + +describe('SalesAgentsResource', () => { + let mockAdapter: jest.Mocked; + let resource: SalesAgentsResource; + + beforeEach(() => { + mockAdapter = createMockAdapter(); + resource = new SalesAgentsResource(mockAdapter); + jest.clearAllMocks(); + }); + + describe('list', () => { + it('should call adapter with correct path and no params', async () => { + mockAdapter.request.mockResolvedValue({ items: [] }); + mockShouldValidateResponse.mockReturnValue(false); + + await resource.list(); + + expect(mockAdapter.request).toHaveBeenCalledWith('GET', '/sales-agents', undefined, { + params: { + status: undefined, + relationship: undefined, + name: undefined, + limit: undefined, + offset: undefined, + }, + }); + }); + + it('should pass filter params when provided', async () => { + mockAdapter.request.mockResolvedValue({ items: [] }); + mockShouldValidateResponse.mockReturnValue(false); + + await resource.list({ + status: 'ACTIVE', + relationship: 'CONNECTED', + name: 'Test Agent', + limit: 10, + offset: 20, + }); + + expect(mockAdapter.request).toHaveBeenCalledWith('GET', '/sales-agents', undefined, { + params: { + status: 'ACTIVE', + relationship: 'CONNECTED', + name: 'Test Agent', + limit: 10, + offset: 20, + }, + }); + }); + + it('should validate response when validation is enabled', async () => { + const validatingAdapter = createMockAdapter({ validate: true }); + const validatingResource = new SalesAgentsResource(validatingAdapter); + + const mockResult = { items: [{ id: 'sa-1' }] }; + const validatedResult = { items: [{ id: 'sa-1', validated: true }] }; + validatingAdapter.request.mockResolvedValue(mockResult); + mockShouldValidateResponse.mockReturnValue(true); + mockValidateResponse.mockReturnValue(validatedResult); + + const result = await validatingResource.list(); + + expect(mockShouldValidateResponse).toHaveBeenCalledWith(true); + expect(mockValidateResponse).toHaveBeenCalledWith(salesAgentSchemas.listResponse, mockResult); + expect(result).toBe(validatedResult); + }); + }); + + describe('registerAccount', () => { + it('should call adapter with correct path and body', async () => { + const input = { advertiserId: 'adv-1', accountIdentifier: 'acc-ident-1' }; + mockAdapter.request.mockResolvedValue({ id: 'acc-1' }); + mockShouldValidateInput.mockReturnValue(false); + mockShouldValidateResponse.mockReturnValue(false); + + await resource.registerAccount('agent-123', input); + + expect(mockAdapter.request).toHaveBeenCalledWith( + 'POST', + '/sales-agents/agent-123/accounts', + input + ); + }); + + it('should validate input and response when validation is enabled', async () => { + const validatingAdapter = createMockAdapter({ validate: true }); + const validatingResource = new SalesAgentsResource(validatingAdapter); + + const input = { advertiserId: 'adv-1', accountIdentifier: 'acc-ident-1' }; + const validatedInput = { + advertiserId: 'adv-1', + accountIdentifier: 'acc-ident-1', + validated: true, + }; + const mockResult = { id: 'acc-1' }; + const validatedResult = { id: 'acc-1', validated: true }; + + mockShouldValidateInput.mockReturnValue(true); + mockShouldValidateResponse.mockReturnValue(true); + mockValidateInput.mockReturnValue(validatedInput); + validatingAdapter.request.mockResolvedValue(mockResult); + mockValidateResponse.mockReturnValue(validatedResult); + + const result = await validatingResource.registerAccount('agent-123', input); + + expect(mockValidateInput).toHaveBeenCalledWith(salesAgentSchemas.registerAccountInput, input); + expect(validatingAdapter.request).toHaveBeenCalledWith( + 'POST', + '/sales-agents/agent-123/accounts', + validatedInput + ); + expect(mockValidateResponse).toHaveBeenCalledWith( + salesAgentSchemas.accountResponse, + mockResult + ); + expect(result).toBe(validatedResult); + }); + }); +}); diff --git a/src/__tests__/resources/signals.test.ts b/src/__tests__/resources/signals.test.ts new file mode 100644 index 0000000..f4a57be --- /dev/null +++ b/src/__tests__/resources/signals.test.ts @@ -0,0 +1,58 @@ +/** + * Tests for SignalsResource + */ + +import { SignalsResource } from '../../resources/signals'; +import type { BaseAdapter } from '../../adapters/base'; + +describe('SignalsResource', () => { + let mockAdapter: jest.Mocked; + let resource: SignalsResource; + + beforeEach(() => { + mockAdapter = { + baseUrl: 'https://api.test.com', + version: 'v2', + persona: 'buyer' as const, + debug: false, + validate: false, + request: jest.fn(), + connect: jest.fn(), + disconnect: jest.fn(), + }; + resource = new SignalsResource(mockAdapter); + }); + + describe('discover', () => { + it('should call adapter with correct path and body', async () => { + const input = { filters: { catalogTypes: ['PRODUCT'] } }; + mockAdapter.request.mockResolvedValue([{ id: 'sig-1' }]); + + await resource.discover(input); + + expect(mockAdapter.request).toHaveBeenCalledWith('POST', '/campaign/signals/discover', input); + }); + + it('should call adapter with no data when none provided', async () => { + mockAdapter.request.mockResolvedValue([]); + + await resource.discover(); + + expect(mockAdapter.request).toHaveBeenCalledWith( + 'POST', + '/campaign/signals/discover', + undefined + ); + }); + }); + + describe('list', () => { + it('should call adapter with correct path', async () => { + mockAdapter.request.mockResolvedValue([{ id: 'sig-1' }, { id: 'sig-2' }]); + + await resource.list(); + + expect(mockAdapter.request).toHaveBeenCalledWith('GET', '/signals'); + }); + }); +}); diff --git a/src/__tests__/resources/test-cohorts.test.ts b/src/__tests__/resources/test-cohorts.test.ts new file mode 100644 index 0000000..9229bbb --- /dev/null +++ b/src/__tests__/resources/test-cohorts.test.ts @@ -0,0 +1,51 @@ +/** + * Tests for TestCohortsResource + */ + +import { TestCohortsResource } from '../../resources/test-cohorts'; +import type { BaseAdapter } from '../../adapters/base'; + +describe('TestCohortsResource', () => { + let mockAdapter: jest.Mocked; + let resource: TestCohortsResource; + const advertiserId = 'adv-123'; + + beforeEach(() => { + mockAdapter = { + baseUrl: 'https://api.test.com', + version: 'v2', + persona: 'buyer' as const, + debug: false, + validate: false, + request: jest.fn(), + connect: jest.fn(), + disconnect: jest.fn(), + }; + resource = new TestCohortsResource(mockAdapter, advertiserId); + }); + + describe('list', () => { + it('should call adapter with correct path', async () => { + mockAdapter.request.mockResolvedValue({ items: [] }); + + await resource.list(); + + expect(mockAdapter.request).toHaveBeenCalledWith('GET', '/advertisers/adv-123/test-cohorts'); + }); + }); + + describe('create', () => { + it('should call adapter with correct path and body', async () => { + const input = { name: 'Test Cohort A', splitPercentage: 50 }; + mockAdapter.request.mockResolvedValue({ id: 'tc-1', name: 'Test Cohort A' }); + + await resource.create(input); + + expect(mockAdapter.request).toHaveBeenCalledWith( + 'POST', + '/advertisers/adv-123/test-cohorts', + input + ); + }); + }); +}); diff --git a/src/__tests__/webhook-server.test.ts b/src/__tests__/webhook-server.test.ts index 70d75ed..3e9c90d 100644 --- a/src/__tests__/webhook-server.test.ts +++ b/src/__tests__/webhook-server.test.ts @@ -1,45 +1,695 @@ -import { WebhookServer } from '../webhook-server'; +import { WebhookServer, WebhookHandler } from '../webhook-server'; +import http from 'http'; + +/** + * Helper: make an HTTP request to a running WebhookServer. + * Avoids needing supertest as a dependency. + */ +function makeRequest(options: { + port: number; + method: string; + path: string; + body?: unknown; + headers?: Record; +}): Promise<{ status: number; body: Record }> { + return new Promise((resolve, reject) => { + const payload = options.body ? JSON.stringify(options.body) : undefined; + const req = http.request( + { + hostname: 'localhost', + port: options.port, + path: options.path, + method: options.method, + headers: { + 'Content-Type': 'application/json', + ...options.headers, + ...(payload ? { 'Content-Length': Buffer.byteLength(payload) } : {}), + }, + }, + (res) => { + let data = ''; + res.on('data', (chunk: Buffer) => { + data += chunk.toString(); + }); + res.on('end', () => { + try { + resolve({ status: res.statusCode ?? 0, body: JSON.parse(data) }); + } catch { + resolve({ status: res.statusCode ?? 0, body: {} }); + } + }); + } + ); + req.on('error', reject); + if (payload) { + req.write(payload); + } + req.end(); + }); +} + +/** Pick a random high port to avoid test collisions */ +function randomPort(): number { + return 10000 + Math.floor(Math.random() * 50000); +} describe('WebhookServer', () => { let server: WebhookServer; + let port: number; afterEach(async () => { if (server) { - await server.stop(); + await server.stop().catch(() => { + // Server may already be stopped — ignore close errors + }); } }); - it('should initialize with default config', () => { - server = new WebhookServer(); - expect(server).toBeDefined(); + // ── Construction & defaults ──────────────────────────────────── + + describe('constructor', () => { + it('should initialize with default config', () => { + server = new WebhookServer(); + expect(server).toBeDefined(); + expect(server.getUrl()).toBe('http://localhost:3000/webhooks'); + }); + + it('should accept custom port, path, and secret', () => { + server = new WebhookServer({ port: 4567, path: '/hooks', secret: 's3cret' }); + expect(server.getUrl()).toBe('http://localhost:4567/hooks'); + }); + + it('should warn when no secret is configured', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(); + server = new WebhookServer(); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('No secret configured')); + warnSpy.mockRestore(); + }); + + it('should not warn when secret IS configured', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(); + server = new WebhookServer({ secret: 'tok' }); + expect(warnSpy).not.toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + }); + + // ── getUrl ───────────────────────────────────────────────────── + + describe('getUrl', () => { + it('should return URL based on port and path', () => { + server = new WebhookServer({ port: 8888, path: '/events' }); + expect(server.getUrl()).toBe('http://localhost:8888/events'); + }); + + it('should use defaults when nothing is provided', () => { + server = new WebhookServer(); + expect(server.getUrl()).toBe('http://localhost:3000/webhooks'); + }); + }); + + // ── Handler registration (on / off) ─────────────────────────── + + describe('handler registration', () => { + beforeEach(() => { + server = new WebhookServer(); + }); + + it('should register a handler for a specific event type', () => { + const handler = jest.fn(); + server.on('order.created', handler); + // No assertion on internal state — verified through dispatch tests + expect(handler).not.toHaveBeenCalled(); + }); + + it('should register a wildcard handler', () => { + const handler = jest.fn(); + server.on('*', handler); + expect(handler).not.toHaveBeenCalled(); + }); + + it('should allow multiple handlers for the same event type', () => { + const h1 = jest.fn(); + const h2 = jest.fn(); + server.on('order.created', h1); + server.on('order.created', h2); + // Both registered without error + }); + + it('should allow the same handler for different event types', () => { + const handler = jest.fn(); + server.on('order.created', handler); + server.on('order.updated', handler); + }); + }); + + describe('handler removal', () => { + beforeEach(() => { + server = new WebhookServer(); + }); + + it('off(type) without handler removes all handlers for that type', () => { + const h1 = jest.fn(); + const h2 = jest.fn(); + server.on('order.created', h1); + server.on('order.created', h2); + server.off('order.created'); + // Verified through dispatch tests — handlers should not fire + }); + + it('off(type, handler) removes only the specific handler', () => { + const h1 = jest.fn(); + const h2 = jest.fn(); + server.on('order.created', h1); + server.on('order.created', h2); + server.off('order.created', h1); + // h2 should still be registered (verified through dispatch) + }); + + it('off() is safe when no handlers exist for the type', () => { + expect(() => server.off('nonexistent')).not.toThrow(); + expect(() => server.off('nonexistent', jest.fn())).not.toThrow(); + }); + + it('off() with a handler that was never registered does nothing', () => { + const registered = jest.fn(); + const notRegistered = jest.fn(); + server.on('order.created', registered); + server.off('order.created', notRegistered); + // registered is still there (verified through dispatch) + }); + }); + + // ── Lifecycle (start / stop) ─────────────────────────────────── + + describe('lifecycle', () => { + it('should start and begin listening', async () => { + port = randomPort(); + server = new WebhookServer({ port }); + const logSpy = jest.spyOn(console, 'log').mockImplementation(); + await server.start(); + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining(`listening on port ${port}`)); + logSpy.mockRestore(); + }); + + it('should stop gracefully', async () => { + port = randomPort(); + server = new WebhookServer({ port }); + const logSpy = jest.spyOn(console, 'log').mockImplementation(); + await server.start(); + await server.stop(); + expect(logSpy).toHaveBeenCalledWith('Webhook server stopped'); + logSpy.mockRestore(); + }); + + it('should resolve immediately when stopping a server that was never started', async () => { + server = new WebhookServer(); + await expect(server.stop()).resolves.toBeUndefined(); + }); + + it('should reject start when port is already in use', async () => { + port = randomPort(); + const first = new WebhookServer({ port }); + await first.start(); + + const second = new WebhookServer({ port }); + await expect(second.start()).rejects.toThrow(); + + await first.stop(); + }); + }); + + // ── Health endpoint ──────────────────────────────────────────── + + describe('GET /health', () => { + it('should return status ok', async () => { + port = randomPort(); + server = new WebhookServer({ port }); + jest.spyOn(console, 'log').mockImplementation(); + jest.spyOn(console, 'warn').mockImplementation(); + await server.start(); + + const res = await makeRequest({ port, method: 'GET', path: '/health' }); + expect(res.status).toBe(200); + expect(res.body).toEqual({ status: 'ok' }); + }); + + it('should respond to /health even when secret is configured', async () => { + port = randomPort(); + server = new WebhookServer({ port, secret: 'my-secret' }); + jest.spyOn(console, 'log').mockImplementation(); + await server.start(); + + // /health is behind the auth middleware when secret is set, + // so it should require auth too (express middleware applies globally) + const resNoAuth = await makeRequest({ port, method: 'GET', path: '/health' }); + // The auth middleware is applied globally, so /health requires auth + expect(resNoAuth.status).toBe(401); + + const resWithAuth = await makeRequest({ + port, + method: 'GET', + path: '/health', + headers: { Authorization: 'Bearer my-secret' }, + }); + expect(resWithAuth.status).toBe(200); + expect(resWithAuth.body).toEqual({ status: 'ok' }); + }); + }); + + // ── Event dispatch via HTTP ──────────────────────────────────── + + describe('event dispatch', () => { + beforeEach(async () => { + port = randomPort(); + server = new WebhookServer({ port }); + jest.spyOn(console, 'log').mockImplementation(); + jest.spyOn(console, 'warn').mockImplementation(); + await server.start(); + }); + + it('should invoke the specific handler for a matching event type', async () => { + const handler = jest.fn(); + server.on('order.created', handler); + + const event = { type: 'order.created', timestamp: '2026-01-01T00:00:00Z', data: { id: '1' } }; + const res = await makeRequest({ port, method: 'POST', path: '/webhooks', body: event }); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ success: true }); + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith(event); + }); + + it('should invoke the wildcard handler for any event type', async () => { + const wildcard = jest.fn(); + server.on('*', wildcard); + + const event = { type: 'anything', timestamp: '', data: {} }; + await makeRequest({ port, method: 'POST', path: '/webhooks', body: event }); + + expect(wildcard).toHaveBeenCalledTimes(1); + expect(wildcard).toHaveBeenCalledWith(event); + }); + + it('should invoke both specific and wildcard handlers', async () => { + const specific = jest.fn(); + const wildcard = jest.fn(); + server.on('order.created', specific); + server.on('*', wildcard); + + const event = { type: 'order.created', timestamp: '', data: {} }; + await makeRequest({ port, method: 'POST', path: '/webhooks', body: event }); + + expect(specific).toHaveBeenCalledTimes(1); + expect(wildcard).toHaveBeenCalledTimes(1); + }); + + it('should invoke multiple handlers registered for the same type', async () => { + const h1 = jest.fn(); + const h2 = jest.fn(); + server.on('order.created', h1); + server.on('order.created', h2); + + const event = { type: 'order.created', timestamp: '', data: {} }; + await makeRequest({ port, method: 'POST', path: '/webhooks', body: event }); + + expect(h1).toHaveBeenCalledTimes(1); + expect(h2).toHaveBeenCalledTimes(1); + }); + + it('should not invoke handlers for non-matching event types', async () => { + const handler = jest.fn(); + server.on('order.created', handler); + + const event = { type: 'order.updated', timestamp: '', data: {} }; + await makeRequest({ port, method: 'POST', path: '/webhooks', body: event }); + + expect(handler).not.toHaveBeenCalled(); + }); + + it('should not invoke removed handlers (off with no handler arg)', async () => { + const handler = jest.fn(); + server.on('order.created', handler); + server.off('order.created'); + + const event = { type: 'order.created', timestamp: '', data: {} }; + await makeRequest({ port, method: 'POST', path: '/webhooks', body: event }); + + expect(handler).not.toHaveBeenCalled(); + }); + + it('should not invoke a specific removed handler but still invoke remaining ones', async () => { + const h1 = jest.fn(); + const h2 = jest.fn(); + server.on('order.created', h1); + server.on('order.created', h2); + server.off('order.created', h1); + + const event = { type: 'order.created', timestamp: '', data: {} }; + await makeRequest({ port, method: 'POST', path: '/webhooks', body: event }); + + expect(h1).not.toHaveBeenCalled(); + expect(h2).toHaveBeenCalledTimes(1); + }); + + it('should handle async handlers', async () => { + const order: string[] = []; + const asyncHandler: WebhookHandler = async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + order.push('async'); + }; + const syncHandler: WebhookHandler = () => { + order.push('sync'); + }; + server.on('test', asyncHandler); + server.on('test', syncHandler); + + const event = { type: 'test', timestamp: '', data: {} }; + const res = await makeRequest({ port, method: 'POST', path: '/webhooks', body: event }); + + expect(res.status).toBe(200); + expect(order).toContain('async'); + expect(order).toContain('sync'); + }); + + it('should succeed with 200 when no handlers are registered for the event type', async () => { + const event = { type: 'unhandled.event', timestamp: '', data: {} }; + const res = await makeRequest({ port, method: 'POST', path: '/webhooks', body: event }); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ success: true }); + }); }); - it('should initialize with custom config', () => { - server = new WebhookServer({ - port: 4000, - path: '/custom-webhooks', - secret: 'test-secret', + // ── Invalid events (400) ─────────────────────────────────────── + + describe('invalid events', () => { + beforeEach(async () => { + port = randomPort(); + server = new WebhookServer({ port }); + jest.spyOn(console, 'log').mockImplementation(); + jest.spyOn(console, 'warn').mockImplementation(); + await server.start(); + }); + + it('should return 400 when body is missing the type field', async () => { + const res = await makeRequest({ + port, + method: 'POST', + path: '/webhooks', + body: { data: { foo: 'bar' } }, + }); + expect(res.status).toBe(400); + expect(res.body).toEqual({ error: 'Invalid webhook event format' }); + }); + + it('should return 400 when type is empty string', async () => { + const res = await makeRequest({ + port, + method: 'POST', + path: '/webhooks', + body: { type: '', data: {} }, + }); + expect(res.status).toBe(400); + }); + + it('should return 400 when type is not a string', async () => { + const res = await makeRequest({ + port, + method: 'POST', + path: '/webhooks', + body: { type: 123, data: {} }, + }); + expect(res.status).toBe(400); + }); + + it('should return 400 for null body', async () => { + const res = await makeRequest({ + port, + method: 'POST', + path: '/webhooks', + body: null, + }); + expect(res.status).toBe(400); + }); + + it('should return 400 for an array body', async () => { + const res = await makeRequest({ + port, + method: 'POST', + path: '/webhooks', + body: [{ type: 'order.created' }], + }); + // Arrays technically pass typeof === 'object', but lack type at the top level + // Express parses this as an array which has no .type property + expect(res.status).toBe(400); }); - expect(server).toBeDefined(); }); - it('should register event handlers', () => { - server = new WebhookServer(); - const handler = jest.fn(); - server.on('test-event', handler); - expect(handler).not.toHaveBeenCalled(); + // ── Handler errors (500) ─────────────────────────────────────── + + describe('handler errors', () => { + beforeEach(async () => { + port = randomPort(); + server = new WebhookServer({ port }); + jest.spyOn(console, 'log').mockImplementation(); + jest.spyOn(console, 'warn').mockImplementation(); + await server.start(); + }); + + it('should return 500 when a handler throws synchronously', async () => { + const errorSpy = jest.spyOn(console, 'error').mockImplementation(); + server.on('fail', () => { + throw new Error('handler boom'); + }); + + const res = await makeRequest({ + port, + method: 'POST', + path: '/webhooks', + body: { type: 'fail', timestamp: '', data: {} }, + }); + + expect(res.status).toBe(500); + expect(res.body).toEqual({ error: 'Internal server error' }); + expect(errorSpy).toHaveBeenCalledWith('Webhook handler error:', 'handler boom'); + errorSpy.mockRestore(); + }); + + it('should return 500 when an async handler rejects', async () => { + const errorSpy = jest.spyOn(console, 'error').mockImplementation(); + server.on('fail', async () => { + throw new Error('async boom'); + }); + + const res = await makeRequest({ + port, + method: 'POST', + path: '/webhooks', + body: { type: 'fail', timestamp: '', data: {} }, + }); + + expect(res.status).toBe(500); + expect(res.body).toEqual({ error: 'Internal server error' }); + errorSpy.mockRestore(); + }); + + it('should handle non-Error throws gracefully', async () => { + const errorSpy = jest.spyOn(console, 'error').mockImplementation(); + server.on('fail', () => { + throw 'string error'; // eslint-disable-line no-throw-literal + }); + + const res = await makeRequest({ + port, + method: 'POST', + path: '/webhooks', + body: { type: 'fail', timestamp: '', data: {} }, + }); + + expect(res.status).toBe(500); + expect(errorSpy).toHaveBeenCalledWith('Webhook handler error:', 'Unknown error'); + errorSpy.mockRestore(); + }); + + it('should not crash the server after a handler error', async () => { + jest.spyOn(console, 'error').mockImplementation(); + server.on('fail', () => { + throw new Error('boom'); + }); + + // First request triggers error + await makeRequest({ + port, + method: 'POST', + path: '/webhooks', + body: { type: 'fail', timestamp: '', data: {} }, + }); + + // Server should still be responsive + const healthRes = await makeRequest({ port, method: 'GET', path: '/health' }); + expect(healthRes.status).toBe(200); + + // A valid event should still be processed + const okHandler = jest.fn(); + server.on('ok', okHandler); + const res = await makeRequest({ + port, + method: 'POST', + path: '/webhooks', + body: { type: 'ok', timestamp: '', data: {} }, + }); + expect(res.status).toBe(200); + expect(okHandler).toHaveBeenCalledTimes(1); + }); }); - it('should unregister event handlers', () => { - server = new WebhookServer(); - const handler = jest.fn(); - server.on('test-event', handler); - server.off('test-event', handler); - expect(handler).not.toHaveBeenCalled(); + // ── Authentication ───────────────────────────────────────────── + + describe('authentication', () => { + it('should not require auth when no secret is configured', async () => { + port = randomPort(); + server = new WebhookServer({ port }); + jest.spyOn(console, 'log').mockImplementation(); + jest.spyOn(console, 'warn').mockImplementation(); + await server.start(); + + const res = await makeRequest({ + port, + method: 'POST', + path: '/webhooks', + body: { type: 'test', timestamp: '', data: {} }, + }); + expect(res.status).toBe(200); + }); + + it('should return 401 when secret is set and no Authorization header is sent', async () => { + port = randomPort(); + server = new WebhookServer({ port, secret: 'test-secret' }); + jest.spyOn(console, 'log').mockImplementation(); + await server.start(); + + const res = await makeRequest({ + port, + method: 'POST', + path: '/webhooks', + body: { type: 'test', timestamp: '', data: {} }, + }); + expect(res.status).toBe(401); + expect(res.body).toEqual({ error: 'Unauthorized' }); + }); + + it('should return 401 when the wrong token is provided', async () => { + port = randomPort(); + server = new WebhookServer({ port, secret: 'correct-secret' }); + jest.spyOn(console, 'log').mockImplementation(); + await server.start(); + + const res = await makeRequest({ + port, + method: 'POST', + path: '/webhooks', + body: { type: 'test', timestamp: '', data: {} }, + headers: { Authorization: 'Bearer wrong-secret' }, + }); + expect(res.status).toBe(401); + }); + + it('should return 401 when Authorization header format is wrong', async () => { + port = randomPort(); + server = new WebhookServer({ port, secret: 'my-secret' }); + jest.spyOn(console, 'log').mockImplementation(); + await server.start(); + + const res = await makeRequest({ + port, + method: 'POST', + path: '/webhooks', + body: { type: 'test', timestamp: '', data: {} }, + headers: { Authorization: 'Basic my-secret' }, + }); + expect(res.status).toBe(401); + }); + + it('should accept requests with the correct Bearer token', async () => { + port = randomPort(); + server = new WebhookServer({ port, secret: 'valid-token' }); + jest.spyOn(console, 'log').mockImplementation(); + await server.start(); + + const handler = jest.fn(); + server.on('authed', handler); + + const res = await makeRequest({ + port, + method: 'POST', + path: '/webhooks', + body: { type: 'authed', timestamp: '', data: {} }, + headers: { Authorization: 'Bearer valid-token' }, + }); + + expect(res.status).toBe(200); + expect(handler).toHaveBeenCalledTimes(1); + }); + + it('should reject tokens of different length via timing-safe comparison', async () => { + port = randomPort(); + server = new WebhookServer({ port, secret: 'short' }); + jest.spyOn(console, 'log').mockImplementation(); + await server.start(); + + const res = await makeRequest({ + port, + method: 'POST', + path: '/webhooks', + body: { type: 'test', timestamp: '', data: {} }, + headers: { Authorization: 'Bearer a-much-longer-token-value' }, + }); + expect(res.status).toBe(401); + }); }); - it('should return webhook URL', () => { - server = new WebhookServer({ port: 3000, path: '/webhooks' }); - expect(server.getUrl()).toBe('http://localhost:3000/webhooks'); + // ── Custom path ──────────────────────────────────────────────── + + describe('custom webhook path', () => { + it('should accept events on custom path', async () => { + port = randomPort(); + server = new WebhookServer({ port, path: '/custom/hooks' }); + jest.spyOn(console, 'log').mockImplementation(); + jest.spyOn(console, 'warn').mockImplementation(); + await server.start(); + + const handler = jest.fn(); + server.on('test', handler); + + const res = await makeRequest({ + port, + method: 'POST', + path: '/custom/hooks', + body: { type: 'test', timestamp: '', data: {} }, + }); + + expect(res.status).toBe(200); + expect(handler).toHaveBeenCalledTimes(1); + }); + + it('should return 404 on default path when custom path is configured', async () => { + port = randomPort(); + server = new WebhookServer({ port, path: '/custom/hooks' }); + jest.spyOn(console, 'log').mockImplementation(); + jest.spyOn(console, 'warn').mockImplementation(); + await server.start(); + + const res = await makeRequest({ + port, + method: 'POST', + path: '/webhooks', + body: { type: 'test', timestamp: '', data: {} }, + }); + + // Express returns 404 for unmatched routes (no body by default) + expect(res.status).toBe(404); + }); }); }); diff --git a/src/adapters/base.ts b/src/adapters/base.ts index 3a8dbfa..31906f9 100644 --- a/src/adapters/base.ts +++ b/src/adapters/base.ts @@ -101,7 +101,13 @@ export function validateResourceId(id: string): string { if (!id || typeof id !== 'string') { throw new Scope3ApiError(400, 'Resource ID is required'); } - if (id.includes('/') || id.includes('\\') || id.includes('?') || id.includes('#')) { + if ( + id.includes('/') || + id.includes('\\') || + id.includes('?') || + id.includes('#') || + id.includes('..') + ) { throw new Scope3ApiError(400, 'Invalid resource ID format'); } return encodeURIComponent(id); diff --git a/src/client.ts b/src/client.ts index fe7e5e6..4c3dec5 100644 --- a/src/client.ts +++ b/src/client.ts @@ -56,7 +56,8 @@ export class Scope3Client { private skillPromise: Promise | null = null; constructor(config: Scope3ClientConfig) { - if (!config.apiKey) { + const trimmedKey = config.apiKey?.trim(); + if (!trimmedKey) { throw new Error('apiKey is required'); } if (!config.persona) { @@ -65,7 +66,7 @@ export class Scope3Client { this.version = config.version ?? 'v2'; this.persona = config.persona; - this.adapter = new RestAdapter(config); + this.adapter = new RestAdapter({ ...config, apiKey: trimmedKey }); switch (this.persona) { case 'buyer': @@ -80,6 +81,10 @@ export class Scope3Client { this._partners = new PartnersResource(this.adapter); this._agents = new AgentsResource(this.adapter); break; + default: { + const _exhaustive: never = this.persona; + throw new Error(`Unknown persona: ${_exhaustive}`); + } } } diff --git a/src/resources/reporting.ts b/src/resources/reporting.ts index 4c91073..9903c35 100644 --- a/src/resources/reporting.ts +++ b/src/resources/reporting.ts @@ -31,7 +31,7 @@ export class ReportingResource { }, }); if (shouldValidateResponse(this.adapter.validate)) { - validateResponse(reportingSchemas.response, result); + return validateResponse(reportingSchemas.response, result) as T; } return result; } diff --git a/src/validation.ts b/src/validation.ts index 04f1314..c6686ef 100644 --- a/src/validation.ts +++ b/src/validation.ts @@ -32,5 +32,7 @@ export function validateResponse(schema: ZodSchema, data: unknown): T { } function formatZodError(error: ZodError): string { - return error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; '); + return error.issues + .map((i) => `${i.path.length ? i.path.join('.') : '(root)'}: ${i.message}`) + .join('; '); } From cc27098e5a2c88259fb5ce6a2c582abae37e6e4f Mon Sep 17 00:00:00 2001 From: Emma Mulitz Date: Tue, 31 Mar 2026 09:50:22 -0400 Subject: [PATCH 4/6] feat: sync SDK with latest v2 API for buyer and storefront personas Replaces the partner persona with storefront across the entire SDK. Adds all missing buyer resources (event sources, measurement data, catalogs, audiences, syndication, tasks, property lists, creatives) and storefront resources (storefront CRUD, inventory sources, readiness, billing, notifications). Syncs bundled skill.md with live API. --- README.md | 50 +- docs/cli-reference.md | 55 +- docs/getting-started.md | 28 +- docs/partner-guide.md | 104 - docs/storefront-guide.md | 172 + src/__tests__/adapters/base.test.ts | 4 +- src/__tests__/client.test.ts | 75 +- src/__tests__/resources/agents.test.ts | 21 +- src/__tests__/resources/billing.test.ts | 77 + .../resources/inventory-sources.test.ts | 75 + src/__tests__/resources/notifications.test.ts | 94 + src/__tests__/resources/partners.test.ts | 94 - src/__tests__/resources/readiness.test.ts | 37 + src/__tests__/resources/storefront.test.ts | 59 + src/__tests__/skill/fetcher.test.ts | 23 +- src/cli/commands/config.ts | 4 +- src/cli/commands/index.ts | 2 +- src/cli/commands/partners.ts | 132 +- src/cli/format.ts | 2 +- src/cli/index.ts | 29 +- src/client.ts | 90 +- src/index.ts | 42 +- src/resources/advertisers.ts | 60 + src/resources/agents.ts | 14 +- src/resources/audiences.ts | 45 + src/resources/billing.ts | 73 + src/resources/campaigns.ts | 10 + src/resources/catalogs.ts | 45 + src/resources/creatives.ts | 84 + src/resources/event-sources.ts | 90 + src/resources/index.ts | 14 +- src/resources/inventory-sources.ts | 75 + src/resources/measurement-data.ts | 29 + src/resources/notifications.ts | 64 + src/resources/partners.ts | 66 - src/resources/property-lists.ts | 107 + src/resources/readiness.ts | 21 + src/resources/storefront.ts | 51 + src/resources/syndication.ts | 61 + src/resources/tasks.ts | 22 + src/skill/bundled.ts | 2812 ++++++++++++----- src/skill/index.ts | 2 +- src/types/index.ts | 170 +- 43 files changed, 3777 insertions(+), 1407 deletions(-) delete mode 100644 docs/partner-guide.md create mode 100644 docs/storefront-guide.md create mode 100644 src/__tests__/resources/billing.test.ts create mode 100644 src/__tests__/resources/inventory-sources.test.ts create mode 100644 src/__tests__/resources/notifications.test.ts delete mode 100644 src/__tests__/resources/partners.test.ts create mode 100644 src/__tests__/resources/readiness.test.ts create mode 100644 src/__tests__/resources/storefront.test.ts create mode 100644 src/resources/audiences.ts create mode 100644 src/resources/billing.ts create mode 100644 src/resources/catalogs.ts create mode 100644 src/resources/creatives.ts create mode 100644 src/resources/event-sources.ts create mode 100644 src/resources/inventory-sources.ts create mode 100644 src/resources/measurement-data.ts create mode 100644 src/resources/notifications.ts delete mode 100644 src/resources/partners.ts create mode 100644 src/resources/property-lists.ts create mode 100644 src/resources/readiness.ts create mode 100644 src/resources/storefront.ts create mode 100644 src/resources/syndication.ts create mode 100644 src/resources/tasks.ts diff --git a/README.md b/README.md index ec265c4..7c51448 100644 --- a/README.md +++ b/README.md @@ -69,27 +69,31 @@ const campaign = await client.campaigns.createDiscovery({ await client.campaigns.execute(campaign.data.id); ``` -#### Partner Persona +#### Storefront Persona ```typescript -const partnerClient = new Scope3Client({ +const sfClient = new Scope3Client({ apiKey: process.env.SCOPE3_API_KEY!, - persona: 'partner', + persona: 'storefront', }); -// List partners -const partners = await partnerClient.partners.list(); +// Get your storefront +const sf = await sfClient.storefront.get(); -// Register an agent -const agent = await partnerClient.agents.register({ - name: 'My Agent', +// Create an inventory source (registers an agent) +const source = await sfClient.inventorySources.create({ + sourceId: 'my-sales-agent', + name: 'My Sales Agent', + executionType: 'agent', type: 'SALES', - partnerId: 'partner-123', endpointUrl: 'https://my-agent.example.com/mcp', protocol: 'MCP', - accountPolicy: ['MARKETPLACE'], authenticationType: 'API_KEY', + auth: { type: 'bearer', token: 'my-api-key' }, }); + +// Check readiness +const readiness = await sfClient.readiness.check(); ``` ### MCP Client (AI Agents) @@ -129,7 +133,7 @@ await mcp.disconnect(); ```typescript const client = new Scope3Client({ apiKey: 'your-api-key', // Required: Bearer token - persona: 'buyer', // Required: 'buyer' | 'partner' + persona: 'buyer', // Required: 'buyer' | 'storefront' environment: 'production', // Optional: 'production' (default) | 'staging' baseUrl: 'https://custom.com', // Optional: overrides environment timeout: 30000, // Optional: request timeout in ms @@ -162,7 +166,7 @@ scope3 campaigns list --format json scope3 bundles create --advertiser-id adv-123 --channels display,video # Override persona per-command -scope3 --persona partner partners list +scope3 --persona storefront storefront get # See all commands scope3 commands @@ -172,17 +176,23 @@ scope3 commands ### Buyer Resources -- `client.advertisers` -- CRUD and sub-resources (conversionEvents, creativeSets, testCohorts) -- `client.campaigns` -- list, get, createDiscovery, updateDiscovery, createPerformance, updatePerformance, createAudience, execute, pause +- `client.advertisers` -- CRUD and sub-resources (conversionEvents, creativeSets, testCohorts, eventSources, measurementData, catalogs, audiences, syndication, propertyLists) +- `client.campaigns` -- list, get, createDiscovery, updateDiscovery, createPerformance, updatePerformance, createAudience, execute, pause, creatives(campaignId) - `client.bundles` -- create, discoverProducts, browseProducts, products(bundleId) - `client.signals` -- Discover signals - `client.reporting` -- Get reporting metrics - `client.salesAgents` -- List sales agents, register accounts +- `client.tasks` -- Get task status +- `client.propertyListChecks` -- Run and retrieve property list check reports -### Partner Resources +### Storefront Resources -- `client.partners` -- list, create, update, archive -- `client.agents` -- list, get, register, update +- `client.storefront` -- get, create, update, delete +- `client.inventorySources` -- list, get, create, update, delete +- `client.agents` -- list, get, update +- `client.readiness` -- check +- `client.billing` -- get, connect, status, transactions, payouts, onboardingUrl +- `client.notifications` -- list, markAsRead, acknowledge, markAllAsRead ## skill.md Support @@ -223,7 +233,7 @@ The SDK is manually maintained. When the Agentic API changes, update these files 1. Check the latest skill.md for your persona: ```bash curl https://api.agentic.scope3.com/api/v2/buyer/skill.md - curl https://api.agentic.scope3.com/api/v2/partner/skill.md + curl https://api.agentic.scope3.com/api/v2/storefront/skill.md ``` 2. Compare against `src/skill/bundled.ts` and update if needed 3. Update types in `src/types/index.ts` to match any schema changes @@ -237,7 +247,7 @@ The SDK is manually maintained. When the Agentic API changes, update these files ```bash export SCOPE3_API_KEY=your_key npm run test:buyer # Buyer workflow -npm run test:partner # Partner workflow +npm run test:storefront # Storefront workflow npm run test:all # All workflows ``` @@ -245,7 +255,7 @@ npm run test:all # All workflows - [Getting Started](docs/getting-started.md) - [Buyer Guide](docs/buyer-guide.md) -- [Partner Guide](docs/partner-guide.md) +- [Storefront Guide](docs/storefront-guide.md) - [CLI Reference](docs/cli-reference.md) ## Contributing diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 7b0b08a..e37fa71 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -22,8 +22,8 @@ scope3 config set apiKey your_api_key_here # List advertisers (buyer persona, default) scope3 advertisers list -# List partners (partner persona) -scope3 --persona partner partners list +# Get storefront (storefront persona) +scope3 --persona storefront storefront get ``` ## Configuration @@ -62,7 +62,7 @@ export SCOPE3_PERSONA=buyer | Option | Description | |---|---| | `--api-key ` | API key (overrides config and environment variable) | -| `--persona ` | Persona to use: `buyer`, `partner` (overrides config and environment variable) | +| `--persona ` | Persona to use: `buyer`, `storefront` (overrides config and environment variable) | | `--environment ` | Target environment: `production`, `staging` | | `--base-url ` | Custom API base URL | | `--format ` | Output format: `table` (default), `json`, `yaml` | @@ -173,31 +173,31 @@ scope3 sales-agents list scope3 sales-agents register-account --name "Account Name" ``` -## Partner Commands +## Storefront Commands -Partner commands require the `partner` persona. +Storefront commands require the `storefront` persona. ```bash -# List partners -scope3 --persona partner partners list +# Get storefront +scope3 --persona storefront storefront get -# Create a partner -scope3 --persona partner partners create --name "My Org" +# Create storefront +scope3 --persona storefront storefront create --name "My Storefront" -# Update a partner -scope3 --persona partner partners update --name "New Name" +# List inventory sources +scope3 --persona storefront inventory-sources list -# Archive a partner -scope3 --persona partner partners archive +# Get an inventory source +scope3 --persona storefront inventory-sources get # List agents -scope3 --persona partner agents list +scope3 --persona storefront agents list # Get agent details -scope3 --persona partner agents get +scope3 --persona storefront agents get -# Register an agent -scope3 --persona partner agents register --name "My Agent" --type SALES --partner-id +# Check readiness +scope3 --persona storefront readiness check ``` ## Output Formats @@ -285,17 +285,22 @@ sales-agents list List sales agents register-account Register account for a sales agent -partners (--persona partner) - list List partners - create Create a partner - update Update a partner - archive Archive a partner +storefront (--persona storefront) + get Get storefront + create Create storefront + update Update storefront + delete Delete storefront -agents (--persona partner) +inventory-sources (--persona storefront) + list List inventory sources + get Get inventory source by ID + create Create an inventory source + update Update an inventory source + delete Delete an inventory source + +agents (--persona storefront) list List agents get Get agent by ID - register Register an agent - update Update an agent config set Set a configuration value diff --git a/docs/getting-started.md b/docs/getting-started.md index cc77838..0d387d0 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -1,6 +1,6 @@ # Getting Started with the Scope3 SDK -The `scope3` npm package provides a TypeScript/JavaScript client for the Scope3 Agentic Platform. It provides two clients -- `Scope3Client` for REST consumers and `Scope3McpClient` for AI agents using MCP -- and organizes functionality around two personas: buyer and partner. +The `scope3` npm package provides a TypeScript/JavaScript client for the Scope3 Agentic Platform. It provides two clients -- `Scope3Client` for REST consumers and `Scope3McpClient` for AI agents using MCP -- and organizes functionality around two personas: buyer and storefront. ## Installation @@ -23,8 +23,8 @@ The SDK requires a persona to determine which API surface is available. There ar | Persona | Use Case | |------------|-----------------------------------------------------------------------------------------------| -| `buyer` | Programmatic advertising -- manage advertisers, campaigns, bundles, and inventory discovery. | -| `partner` | Integration partners -- manage partners and agents. | +| `buyer` | Programmatic advertising -- manage advertisers, campaigns, bundles, and inventory discovery. | +| `storefront` | Storefronts -- manage your storefront, inventory sources, agents, billing, and readiness. | Resources are scoped by persona. Attempting to access a resource outside of your chosen persona will throw an error at runtime. @@ -35,7 +35,7 @@ import { Scope3Client } from 'scope3'; const client = new Scope3Client({ apiKey: process.env.SCOPE3_API_KEY!, - persona: 'buyer', // or 'partner' + persona: 'buyer', // or 'storefront' }); ``` @@ -48,8 +48,8 @@ interface Scope3ClientConfig { /** API key (Bearer token) for authentication. Required. */ apiKey: string; - /** API persona -- buyer or partner. Required. */ - persona: 'buyer' | 'partner'; + /** API persona -- buyer or storefront. Required. */ + persona: 'buyer' | 'storefront'; /** API version to use. Default: 'v2'. */ version?: 'v1' | 'v2' | 'latest'; @@ -139,19 +139,23 @@ const bundle = await client.bundles.create({ console.log('Bundle:', bundle); ``` -### Partner persona +### Storefront persona ```typescript import { Scope3Client } from 'scope3'; const client = new Scope3Client({ apiKey: process.env.SCOPE3_API_KEY!, - persona: 'partner', + persona: 'storefront', }); -// List partners -const partners = await client.partners.list(); -console.log('Partners:', partners); +// Get your storefront +const sf = await client.storefront.get(); +console.log('Storefront:', sf); + +// List inventory sources +const sources = await client.inventorySources.list(); +console.log('Sources:', sources); ``` ## Error Handling @@ -183,5 +187,5 @@ try { ## Next Steps - [Buyer Guide](./buyer-guide.md) -- Full buyer workflow: advertisers, campaigns, bundles, and inventory discovery. -- [Partner Guide](./partner-guide.md) -- Partner integration, agent management, and OAuth. +- [Storefront Guide](./storefront-guide.md) -- Storefront management, inventory sources, billing, and readiness. - [CLI Reference](./cli-reference.md) -- Command-line interface usage. diff --git a/docs/partner-guide.md b/docs/partner-guide.md deleted file mode 100644 index efaaa29..0000000 --- a/docs/partner-guide.md +++ /dev/null @@ -1,104 +0,0 @@ -# Partner Persona Guide - -The partner persona enables integration partners to manage their organization and register agents on the Scope3 Agentic Platform. - -## Setup - -```typescript -import { Scope3Client } from 'scope3'; - -const client = new Scope3Client({ - apiKey: process.env.SCOPE3_API_KEY!, - persona: 'partner', -}); -``` - -## Partners - -Manage partner organizations. - -### List Partners - -```typescript -const partners = await client.partners.list(); -``` - -### Create a Partner - -```typescript -const partner = await client.partners.create({ - name: 'My Organization', - description: 'Ad tech partner', -}); -``` - -### Update a Partner - -```typescript -await client.partners.update('partner-123', { - name: 'Updated Name', -}); -``` - -### Archive a Partner - -```typescript -await client.partners.archive('partner-123'); -``` - -## Agents - -Register and manage agents that interact with the platform. - -### List Agents - -```typescript -const agents = await client.agents.list(); -``` - -### Get an Agent - -```typescript -const agent = await client.agents.get('agent-123'); -``` - -### Register an Agent - -```typescript -const agent = await client.agents.register({ - name: 'My Sales Agent', - type: 'SALES', - partnerId: 'partner-123', -}); -``` - -### Update an Agent - -```typescript -await client.agents.update('agent-123', { - name: 'Updated Agent Name', -}); -``` - -## OAuth Authorization - -Agents can be authorized via OAuth flows. - -### Authorize an Agent - -```typescript -const auth = await client.agents.authorizeOAuth('agent-123', { - redirectUri: 'https://myapp.com/callback', - scopes: ['read', 'write'], -}); -// Redirect user to auth.authorizationUrl -``` - -### Exchange Authorization Code - -```typescript -const tokens = await client.agents.exchangeOAuthCode('agent-123', { - code: 'auth-code-from-callback', - redirectUri: 'https://myapp.com/callback', -}); -``` diff --git a/docs/storefront-guide.md b/docs/storefront-guide.md new file mode 100644 index 0000000..5f476f2 --- /dev/null +++ b/docs/storefront-guide.md @@ -0,0 +1,172 @@ +# Storefront Persona Guide + +The storefront persona enables you to manage your storefront, inventory sources (agent registration), billing, readiness, and notifications on the Scope3 Agentic Platform. + +## Setup + +```typescript +import { Scope3Client } from 'scope3'; + +const client = new Scope3Client({ + apiKey: process.env.SCOPE3_API_KEY!, + persona: 'storefront', +}); +``` + +## Storefront + +Manage your storefront. + +### Get Storefront + +```typescript +const sf = await client.storefront.get(); +``` + +### Create Storefront + +```typescript +const sf = await client.storefront.create({ + name: 'My Storefront', + description: 'Ad tech storefront', +}); +``` + +### Update Storefront + +```typescript +await client.storefront.update({ + name: 'Updated Name', +}); +``` + +### Delete Storefront + +```typescript +await client.storefront.delete(); +``` + +## Inventory Sources + +Register and manage inventory sources (agents). + +### List Inventory Sources + +```typescript +const sources = await client.inventorySources.list(); +``` + +### Get an Inventory Source + +```typescript +const source = await client.inventorySources.get('source-123'); +``` + +### Create an Inventory Source + +```typescript +const source = await client.inventorySources.create({ + sourceId: 'my-sales-agent', + name: 'My Sales Agent', + executionType: 'agent', + type: 'SALES', + endpointUrl: 'https://my-agent.example.com/mcp', + protocol: 'MCP', + authenticationType: 'API_KEY', + auth: { type: 'bearer', token: 'my-api-key' }, +}); +``` + +### Update an Inventory Source + +```typescript +await client.inventorySources.update('source-123', { + name: 'Updated Source Name', +}); +``` + +### Delete an Inventory Source + +```typescript +await client.inventorySources.delete('source-123'); +``` + +## Agents + +List and manage agents (read-only discovery). + +### List Agents + +```typescript +const agents = await client.agents.list(); +``` + +### Get an Agent + +```typescript +const agent = await client.agents.get('agent-123'); +``` + +## Readiness + +Check your storefront's readiness status. + +```typescript +const readiness = await client.readiness.check(); +``` + +## Billing + +Manage Stripe Connect billing integration. + +### Get Billing Info + +```typescript +const billing = await client.billing.get(); +``` + +### Connect Stripe + +```typescript +const connect = await client.billing.connect(); +``` + +### Check Billing Status + +```typescript +const status = await client.billing.status(); +``` + +### Get Onboarding URL + +```typescript +const url = await client.billing.onboardingUrl(); +``` + +## Notifications + +View and manage notifications. + +### List Notifications + +```typescript +const notifications = await client.notifications.list(); +``` + +### Mark as Read + +```typescript +await client.notifications.markAsRead('notification-123'); +``` + +### Acknowledge + +```typescript +await client.notifications.acknowledge('notification-123'); +``` + +### Mark All as Read + +```typescript +await client.notifications.markAllAsRead(); +``` diff --git a/src/__tests__/adapters/base.test.ts b/src/__tests__/adapters/base.test.ts index 3ee7a28..3ce863b 100644 --- a/src/__tests__/adapters/base.test.ts +++ b/src/__tests__/adapters/base.test.ts @@ -111,8 +111,8 @@ describe('resolvePersona', () => { expect(resolvePersona({ apiKey: 'k', persona: 'buyer' })).toBe('buyer'); }); - it('should return partner', () => { - expect(resolvePersona({ apiKey: 'k', persona: 'partner' })).toBe('partner'); + it('should return storefront', () => { + expect(resolvePersona({ apiKey: 'k', persona: 'storefront' })).toBe('storefront'); }); }); diff --git a/src/__tests__/client.test.ts b/src/__tests__/client.test.ts index b06c6f2..5c7c67c 100644 --- a/src/__tests__/client.test.ts +++ b/src/__tests__/client.test.ts @@ -151,34 +151,69 @@ describe('Scope3Client', () => { }); }); - describe('partner persona resources', () => { - it('should have partners resource', () => { - const client = new Scope3Client({ apiKey: 'test-key', persona: 'partner' }); - expect(client.partners).toBeDefined(); - expect(typeof client.partners.list).toBe('function'); - expect(typeof client.partners.create).toBe('function'); - expect(typeof client.partners.update).toBe('function'); - expect(typeof client.partners.archive).toBe('function'); + describe('storefront persona resources', () => { + it('should have storefront resource', () => { + const client = new Scope3Client({ apiKey: 'test-key', persona: 'storefront' }); + expect(client.storefront).toBeDefined(); + expect(typeof client.storefront.get).toBe('function'); + expect(typeof client.storefront.create).toBe('function'); + expect(typeof client.storefront.update).toBe('function'); + expect(typeof client.storefront.delete).toBe('function'); + }); + + it('should have inventorySources resource', () => { + const client = new Scope3Client({ apiKey: 'test-key', persona: 'storefront' }); + expect(client.inventorySources).toBeDefined(); + expect(typeof client.inventorySources.list).toBe('function'); + expect(typeof client.inventorySources.get).toBe('function'); + expect(typeof client.inventorySources.create).toBe('function'); + expect(typeof client.inventorySources.update).toBe('function'); + expect(typeof client.inventorySources.delete).toBe('function'); }); it('should have agents resource', () => { - const client = new Scope3Client({ apiKey: 'test-key', persona: 'partner' }); + const client = new Scope3Client({ apiKey: 'test-key', persona: 'storefront' }); expect(client.agents).toBeDefined(); expect(typeof client.agents.list).toBe('function'); expect(typeof client.agents.get).toBe('function'); - expect(typeof client.agents.register).toBe('function'); expect(typeof client.agents.update).toBe('function'); }); + it('should have readiness resource', () => { + const client = new Scope3Client({ apiKey: 'test-key', persona: 'storefront' }); + expect(client.readiness).toBeDefined(); + expect(typeof client.readiness.check).toBe('function'); + }); + + it('should have billing resource', () => { + const client = new Scope3Client({ apiKey: 'test-key', persona: 'storefront' }); + expect(client.billing).toBeDefined(); + expect(typeof client.billing.get).toBe('function'); + expect(typeof client.billing.connect).toBe('function'); + expect(typeof client.billing.status).toBe('function'); + expect(typeof client.billing.transactions).toBe('function'); + expect(typeof client.billing.payouts).toBe('function'); + expect(typeof client.billing.onboardingUrl).toBe('function'); + }); + + it('should have notifications resource', () => { + const client = new Scope3Client({ apiKey: 'test-key', persona: 'storefront' }); + expect(client.notifications).toBeDefined(); + expect(typeof client.notifications.list).toBe('function'); + expect(typeof client.notifications.markAsRead).toBe('function'); + expect(typeof client.notifications.acknowledge).toBe('function'); + expect(typeof client.notifications.markAllAsRead).toBe('function'); + }); + it('should NOT have advertisers', () => { - const client = new Scope3Client({ apiKey: 'test-key', persona: 'partner' }); + const client = new Scope3Client({ apiKey: 'test-key', persona: 'storefront' }); expect(() => client.advertisers).toThrow( 'advertisers is only available with the buyer persona' ); }); it('should NOT have campaigns', () => { - const client = new Scope3Client({ apiKey: 'test-key', persona: 'partner' }); + const client = new Scope3Client({ apiKey: 'test-key', persona: 'storefront' }); expect(() => client.campaigns).toThrow('campaigns is only available with the buyer persona'); }); }); @@ -288,15 +323,15 @@ describe('Scope3Client', () => { expect(result).toEqual(fakeParsed); }); - it('should pass correct params for partner persona', async () => { - const partnerClient = new Scope3Client({ apiKey: 'test-key', persona: 'partner' }); + it('should pass correct params for storefront persona', async () => { + const sfClient = new Scope3Client({ apiKey: 'test-key', persona: 'storefront' }); mockFetchSkillMd.mockResolvedValue('markdown'); - mockParseSkillMd.mockReturnValue({ ...fakeParsed, name: 'scope3-agentic-partner' }); + mockParseSkillMd.mockReturnValue({ ...fakeParsed, name: 'scope3-agentic-storefront' }); - await partnerClient.getSkill(); + await sfClient.getSkill(); expect(mockFetchSkillMd).toHaveBeenCalledWith( - expect.objectContaining({ persona: 'partner' }) + expect.objectContaining({ persona: 'storefront' }) ); }); @@ -413,16 +448,16 @@ describe('Scope3Client', () => { }); }); - describe('partner persona cannot access buyer sub-resources', () => { + describe('storefront persona cannot access buyer sub-resources', () => { it('should throw when accessing advertisers sub-resources', () => { - const client = new Scope3Client({ apiKey: 'test-key', persona: 'partner' }); + const client = new Scope3Client({ apiKey: 'test-key', persona: 'storefront' }); expect(() => client.advertisers.conversionEvents('adv-1')).toThrow( 'advertisers is only available with the buyer persona' ); }); it('should throw when accessing bundles sub-resources', () => { - const client = new Scope3Client({ apiKey: 'test-key', persona: 'partner' }); + const client = new Scope3Client({ apiKey: 'test-key', persona: 'storefront' }); expect(() => client.bundles.products('bundle-1')).toThrow( 'bundles is only available with the buyer persona' ); diff --git a/src/__tests__/resources/agents.test.ts b/src/__tests__/resources/agents.test.ts index 755339a..50a1a19 100644 --- a/src/__tests__/resources/agents.test.ts +++ b/src/__tests__/resources/agents.test.ts @@ -13,7 +13,7 @@ describe('AgentsResource', () => { mockAdapter = { baseUrl: 'https://api.test.com', version: 'v2', - persona: 'partner' as const, + persona: 'storefront' as const, debug: false, validate: false, request: jest.fn(), @@ -67,25 +67,6 @@ describe('AgentsResource', () => { }); }); - describe('register', () => { - it('should call adapter with correct path and body', async () => { - const input = { - partnerId: 'p-1', - type: 'SALES' as const, - name: 'New Agent', - endpointUrl: 'https://agent.example.com', - protocol: 'MCP' as const, - accountPolicy: ['READ'], - authenticationType: 'API_KEY' as const, - }; - mockAdapter.request.mockResolvedValue({ id: 'agent-1', name: 'New Agent' }); - - await resource.register(input); - - expect(mockAdapter.request).toHaveBeenCalledWith('POST', '/agents', input); - }); - }); - describe('update', () => { it('should call adapter with correct path and body', async () => { const input = { name: 'Updated Agent' }; diff --git a/src/__tests__/resources/billing.test.ts b/src/__tests__/resources/billing.test.ts new file mode 100644 index 0000000..f2fee7c --- /dev/null +++ b/src/__tests__/resources/billing.test.ts @@ -0,0 +1,77 @@ +/** + * Tests for BillingResource + */ + +import { BillingResource } from '../../resources/billing'; +import type { BaseAdapter } from '../../adapters/base'; + +describe('BillingResource', () => { + let mockAdapter: jest.Mocked; + let resource: BillingResource; + + beforeEach(() => { + mockAdapter = { + baseUrl: 'https://api.test.com', + version: 'v2', + persona: 'storefront' as const, + debug: false, + validate: false, + request: jest.fn(), + connect: jest.fn(), + disconnect: jest.fn(), + }; + resource = new BillingResource(mockAdapter); + }); + + describe('get', () => { + it('should call adapter with correct path', async () => { + mockAdapter.request.mockResolvedValue({ billing: null }); + await resource.get(); + expect(mockAdapter.request).toHaveBeenCalledWith('GET', '/billing'); + }); + }); + + describe('connect', () => { + it('should call adapter with correct path', async () => { + mockAdapter.request.mockResolvedValue({ onboardingUrl: 'https://stripe.com/...' }); + await resource.connect(); + expect(mockAdapter.request).toHaveBeenCalledWith('POST', '/billing/connect'); + }); + }); + + describe('status', () => { + it('should call adapter with correct path', async () => { + mockAdapter.request.mockResolvedValue({ verified: true }); + await resource.status(); + expect(mockAdapter.request).toHaveBeenCalledWith('GET', '/billing/status'); + }); + }); + + describe('transactions', () => { + it('should call adapter with correct path and params', async () => { + mockAdapter.request.mockResolvedValue({ data: [] }); + await resource.transactions({ limit: 10, starting_after: 'txn_abc' }); + expect(mockAdapter.request).toHaveBeenCalledWith('GET', '/billing/transactions', undefined, { + params: { limit: 10, starting_after: 'txn_abc' }, + }); + }); + }); + + describe('payouts', () => { + it('should call adapter with correct path and params', async () => { + mockAdapter.request.mockResolvedValue({ data: [] }); + await resource.payouts({ limit: 25 }); + expect(mockAdapter.request).toHaveBeenCalledWith('GET', '/billing/payouts', undefined, { + params: { limit: 25, starting_after: undefined }, + }); + }); + }); + + describe('onboardingUrl', () => { + it('should call adapter with correct path', async () => { + mockAdapter.request.mockResolvedValue({ url: 'https://stripe.com/...' }); + await resource.onboardingUrl(); + expect(mockAdapter.request).toHaveBeenCalledWith('GET', '/billing/onboard'); + }); + }); +}); diff --git a/src/__tests__/resources/inventory-sources.test.ts b/src/__tests__/resources/inventory-sources.test.ts new file mode 100644 index 0000000..8ebf778 --- /dev/null +++ b/src/__tests__/resources/inventory-sources.test.ts @@ -0,0 +1,75 @@ +/** + * Tests for InventorySourcesResource + */ + +import { InventorySourcesResource } from '../../resources/inventory-sources'; +import type { BaseAdapter } from '../../adapters/base'; + +describe('InventorySourcesResource', () => { + let mockAdapter: jest.Mocked; + let resource: InventorySourcesResource; + + beforeEach(() => { + mockAdapter = { + baseUrl: 'https://api.test.com', + version: 'v2', + persona: 'storefront' as const, + debug: false, + validate: false, + request: jest.fn(), + connect: jest.fn(), + disconnect: jest.fn(), + }; + resource = new InventorySourcesResource(mockAdapter); + }); + + describe('list', () => { + it('should call adapter with correct path', async () => { + mockAdapter.request.mockResolvedValue([]); + await resource.list(); + expect(mockAdapter.request).toHaveBeenCalledWith('GET', '/inventory-sources'); + }); + }); + + describe('get', () => { + it('should call adapter with correct path', async () => { + mockAdapter.request.mockResolvedValue({ sourceId: 'src-1' }); + await resource.get('src-1'); + expect(mockAdapter.request).toHaveBeenCalledWith('GET', '/inventory-sources/src-1'); + }); + }); + + describe('create', () => { + it('should call adapter with correct path and body', async () => { + const input = { + sourceId: 'snap-agent', + name: 'Snap Agent', + executionType: 'agent' as const, + type: 'SALES' as const, + endpointUrl: 'https://agent.example.com', + protocol: 'MCP' as const, + authenticationType: 'API_KEY' as const, + }; + mockAdapter.request.mockResolvedValue({ sourceId: 'snap-agent', agentId: 'snap_abc' }); + await resource.create(input); + expect(mockAdapter.request).toHaveBeenCalledWith('POST', '/inventory-sources', input); + }); + }); + + describe('update', () => { + it('should call adapter with correct path and body', async () => { + const input = { status: 'active' }; + mockAdapter.request.mockResolvedValue({ sourceId: 'src-1', status: 'active' }); + await resource.update('src-1', input); + expect(mockAdapter.request).toHaveBeenCalledWith('PUT', '/inventory-sources/src-1', input); + }); + }); + + describe('delete', () => { + it('should call adapter with correct path', async () => { + mockAdapter.request.mockResolvedValue(undefined); + await resource.delete('src-1'); + expect(mockAdapter.request).toHaveBeenCalledWith('DELETE', '/inventory-sources/src-1'); + }); + }); +}); diff --git a/src/__tests__/resources/notifications.test.ts b/src/__tests__/resources/notifications.test.ts new file mode 100644 index 0000000..0ad2f91 --- /dev/null +++ b/src/__tests__/resources/notifications.test.ts @@ -0,0 +1,94 @@ +/** + * Tests for NotificationsResource + */ + +import { NotificationsResource } from '../../resources/notifications'; +import type { BaseAdapter } from '../../adapters/base'; + +describe('NotificationsResource', () => { + let mockAdapter: jest.Mocked; + let resource: NotificationsResource; + + beforeEach(() => { + mockAdapter = { + baseUrl: 'https://api.test.com', + version: 'v2', + persona: 'storefront' as const, + debug: false, + validate: false, + request: jest.fn(), + connect: jest.fn(), + disconnect: jest.fn(), + }; + resource = new NotificationsResource(mockAdapter); + }); + + describe('list', () => { + it('should call adapter with correct path and no params', async () => { + mockAdapter.request.mockResolvedValue([]); + await resource.list(); + expect(mockAdapter.request).toHaveBeenCalledWith('GET', '/notifications', undefined, { + params: { + unreadOnly: undefined, + brandAgentId: undefined, + types: undefined, + limit: undefined, + offset: undefined, + }, + }); + }); + + it('should pass filter params when provided', async () => { + mockAdapter.request.mockResolvedValue([]); + await resource.list({ unreadOnly: true, limit: 20 }); + expect(mockAdapter.request).toHaveBeenCalledWith('GET', '/notifications', undefined, { + params: { + unreadOnly: true, + brandAgentId: undefined, + types: undefined, + limit: 20, + offset: undefined, + }, + }); + }); + }); + + describe('markAsRead', () => { + it('should call adapter with correct path', async () => { + mockAdapter.request.mockResolvedValue(undefined); + await resource.markAsRead('notif-1'); + expect(mockAdapter.request).toHaveBeenCalledWith('POST', '/notifications/notif-1/read'); + }); + }); + + describe('acknowledge', () => { + it('should call adapter with correct path', async () => { + mockAdapter.request.mockResolvedValue(undefined); + await resource.acknowledge('notif-1'); + expect(mockAdapter.request).toHaveBeenCalledWith( + 'POST', + '/notifications/notif-1/acknowledge' + ); + }); + }); + + describe('markAllAsRead', () => { + it('should call adapter with correct path and no body', async () => { + mockAdapter.request.mockResolvedValue(undefined); + await resource.markAllAsRead(); + expect(mockAdapter.request).toHaveBeenCalledWith( + 'POST', + '/notifications/read-all', + undefined + ); + }); + + it('should pass brandAgentId when provided', async () => { + mockAdapter.request.mockResolvedValue(undefined); + await resource.markAllAsRead(123); + expect(mockAdapter.request).toHaveBeenCalledWith('POST', '/notifications/read-all', { + brandAgentId: 123, + }); + }); + }); +}); diff --git a/src/__tests__/resources/partners.test.ts b/src/__tests__/resources/partners.test.ts deleted file mode 100644 index 08585b7..0000000 --- a/src/__tests__/resources/partners.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -/** - * Tests for PartnersResource - */ - -import { PartnersResource } from '../../resources/partners'; -import type { BaseAdapter } from '../../adapters/base'; - -describe('PartnersResource', () => { - let mockAdapter: jest.Mocked; - let resource: PartnersResource; - - beforeEach(() => { - mockAdapter = { - baseUrl: 'https://api.test.com', - version: 'v2', - persona: 'partner' as const, - debug: false, - validate: false, - request: jest.fn(), - connect: jest.fn(), - disconnect: jest.fn(), - }; - resource = new PartnersResource(mockAdapter); - }); - - describe('list', () => { - it('should call adapter with correct path and no params', async () => { - mockAdapter.request.mockResolvedValue({ items: [] }); - - await resource.list(); - - expect(mockAdapter.request).toHaveBeenCalledWith('GET', '/partners', undefined, { - params: { - status: undefined, - name: undefined, - take: undefined, - skip: undefined, - }, - }); - }); - - it('should pass filter params when provided', async () => { - mockAdapter.request.mockResolvedValue({ items: [] }); - - await resource.list({ - status: 'ACTIVE', - name: 'Acme Partner', - take: 10, - skip: 20, - }); - - expect(mockAdapter.request).toHaveBeenCalledWith('GET', '/partners', undefined, { - params: { - status: 'ACTIVE', - name: 'Acme Partner', - take: 10, - skip: 20, - }, - }); - }); - }); - - describe('create', () => { - it('should call adapter with correct path and body', async () => { - const input = { name: 'New Partner', domain: 'partner.com' }; - mockAdapter.request.mockResolvedValue({ id: 'p-1', name: 'New Partner' }); - - await resource.create(input); - - expect(mockAdapter.request).toHaveBeenCalledWith('POST', '/partners', input); - }); - }); - - describe('update', () => { - it('should call adapter with correct path and body', async () => { - const input = { name: 'Updated Partner' }; - mockAdapter.request.mockResolvedValue({ id: 'p-1', name: 'Updated Partner' }); - - await resource.update('p-1', input); - - expect(mockAdapter.request).toHaveBeenCalledWith('PUT', '/partners/p-1', input); - }); - }); - - describe('archive', () => { - it('should call adapter with correct path', async () => { - mockAdapter.request.mockResolvedValue(undefined); - - await resource.archive('p-1'); - - expect(mockAdapter.request).toHaveBeenCalledWith('DELETE', '/partners/p-1'); - }); - }); -}); diff --git a/src/__tests__/resources/readiness.test.ts b/src/__tests__/resources/readiness.test.ts new file mode 100644 index 0000000..474aee6 --- /dev/null +++ b/src/__tests__/resources/readiness.test.ts @@ -0,0 +1,37 @@ +/** + * Tests for ReadinessResource + */ + +import { ReadinessResource } from '../../resources/readiness'; +import type { BaseAdapter } from '../../adapters/base'; + +describe('ReadinessResource', () => { + let mockAdapter: jest.Mocked; + let resource: ReadinessResource; + + beforeEach(() => { + mockAdapter = { + baseUrl: 'https://api.test.com', + version: 'v2', + persona: 'storefront' as const, + debug: false, + validate: false, + request: jest.fn(), + connect: jest.fn(), + disconnect: jest.fn(), + }; + resource = new ReadinessResource(mockAdapter); + }); + + describe('check', () => { + it('should call adapter with correct path', async () => { + mockAdapter.request.mockResolvedValue({ + platformId: 'acme', + status: 'ready', + checks: [], + }); + await resource.check(); + expect(mockAdapter.request).toHaveBeenCalledWith('GET', '/readiness'); + }); + }); +}); diff --git a/src/__tests__/resources/storefront.test.ts b/src/__tests__/resources/storefront.test.ts new file mode 100644 index 0000000..6d773e0 --- /dev/null +++ b/src/__tests__/resources/storefront.test.ts @@ -0,0 +1,59 @@ +/** + * Tests for StorefrontResource + */ + +import { StorefrontResource } from '../../resources/storefront'; +import type { BaseAdapter } from '../../adapters/base'; + +describe('StorefrontResource', () => { + let mockAdapter: jest.Mocked; + let resource: StorefrontResource; + + beforeEach(() => { + mockAdapter = { + baseUrl: 'https://api.test.com', + version: 'v2', + persona: 'storefront' as const, + debug: false, + validate: false, + request: jest.fn(), + connect: jest.fn(), + disconnect: jest.fn(), + }; + resource = new StorefrontResource(mockAdapter); + }); + + describe('get', () => { + it('should call adapter with correct path', async () => { + mockAdapter.request.mockResolvedValue({ platformId: 'acme' }); + await resource.get(); + expect(mockAdapter.request).toHaveBeenCalledWith('GET', ''); + }); + }); + + describe('create', () => { + it('should call adapter with correct path and body', async () => { + const input = { platformId: 'acme', name: 'Acme Media' }; + mockAdapter.request.mockResolvedValue({ platformId: 'acme', name: 'Acme Media' }); + await resource.create(input); + expect(mockAdapter.request).toHaveBeenCalledWith('POST', '', input); + }); + }); + + describe('update', () => { + it('should call adapter with correct path and body', async () => { + const input = { name: 'Updated Name', enabled: true }; + mockAdapter.request.mockResolvedValue({ platformId: 'acme', name: 'Updated Name' }); + await resource.update(input); + expect(mockAdapter.request).toHaveBeenCalledWith('PUT', '', input); + }); + }); + + describe('delete', () => { + it('should call adapter with correct path', async () => { + mockAdapter.request.mockResolvedValue(undefined); + await resource.delete(); + expect(mockAdapter.request).toHaveBeenCalledWith('DELETE', ''); + }); + }); +}); diff --git a/src/__tests__/skill/fetcher.test.ts b/src/__tests__/skill/fetcher.test.ts index e3edb35..07ed2be 100644 --- a/src/__tests__/skill/fetcher.test.ts +++ b/src/__tests__/skill/fetcher.test.ts @@ -78,15 +78,18 @@ describe('fetchSkillMd', () => { ); }); - it('should fetch partner persona skill.md', async () => { + it('should fetch storefront persona skill.md', async () => { mockFetch.mockResolvedValue({ ok: true, - text: () => Promise.resolve('# Partner Skill'), + text: () => Promise.resolve('# Storefront Skill'), }); - await fetchSkillMd({ persona: 'partner' }); + await fetchSkillMd({ persona: 'storefront' }); - expect(mockFetch).toHaveBeenCalledWith(`${baseUrl}/api/v2/partner/skill.md`, expect.anything()); + expect(mockFetch).toHaveBeenCalledWith( + `${baseUrl}/api/v2/storefront/skill.md`, + expect.anything() + ); }); it('should fall back to bundled buyer skill on non-200 response', async () => { @@ -126,15 +129,15 @@ describe('fetchSkillMd', () => { expect(result).toBe(getBundledSkillMdFromBundled('buyer')); }); - it('should fall back to bundled partner skill on non-200 for partner persona', async () => { + it('should fall back to bundled storefront skill on non-200 for storefront persona', async () => { mockFetch.mockResolvedValue({ ok: false, status: 500, }); - const result = await fetchSkillMd({ persona: 'partner' }); + const result = await fetchSkillMd({ persona: 'storefront' }); - expect(result).toBe(getBundledSkillMdFromBundled('partner')); + expect(result).toBe(getBundledSkillMdFromBundled('storefront')); }); }); @@ -149,9 +152,9 @@ describe('getBundledSkillMd', () => { expect(result).toContain('scope3-agentic-buyer'); }); - it('should return partner skill.md content with partner persona', () => { - const result = getBundledSkillMd('partner'); - expect(result).toContain('scope3-agentic-partner'); + it('should return storefront skill.md content with storefront persona', () => { + const result = getBundledSkillMd('storefront'); + expect(result).toContain('scope3-agentic-storefront'); }); it('should contain expected version header', () => { diff --git a/src/cli/commands/config.ts b/src/cli/commands/config.ts index acbbb2b..ae914c2 100644 --- a/src/cli/commands/config.ts +++ b/src/cli/commands/config.ts @@ -40,9 +40,9 @@ configCommand } // Validate persona - if (key === 'persona' && !['buyer', 'partner'].includes(value)) { + if (key === 'persona' && !['buyer', 'storefront'].includes(value)) { console.error(chalk.red(`Invalid persona: ${value}`)); - console.error('Valid personas: buyer, partner'); + console.error('Valid personas: buyer, storefront'); process.exit(1); } diff --git a/src/cli/commands/index.ts b/src/cli/commands/index.ts index 5fd5b84..a782dc8 100644 --- a/src/cli/commands/index.ts +++ b/src/cli/commands/index.ts @@ -8,7 +8,7 @@ export { campaignsCommand } from './campaigns'; export { configCommand } from './config'; export { conversionEventsCommand } from './conversion-events'; export { creativeSetsCommand } from './creative-sets'; -export { partnersCommand, agentsCommand } from './partners'; +export { storefrontCommand, agentsCommand } from './partners'; export { reportingCommand } from './reporting'; export { salesAgentsCommand } from './sales-agents'; export { loginCommand, logoutCommand } from './login'; diff --git a/src/cli/commands/partners.ts b/src/cli/commands/partners.ts index 0f3eea7..642c35f 100644 --- a/src/cli/commands/partners.ts +++ b/src/cli/commands/partners.ts @@ -1,36 +1,26 @@ /** - * Partner and agent commands for the Partner persona + * Storefront and agent commands for the Storefront persona */ import { Command } from 'commander'; import { createClient, GlobalOptions } from '../utils'; import { formatOutput, printError, printSuccess, OutputFormat } from '../format'; -export const partnersCommand = new Command('partners').description( - 'Manage partners and agents (partner persona)' +export const storefrontCommand = new Command('storefront').description( + 'Manage storefront and agents (storefront persona)' ); -// ── Partner CRUD ───────────────────────────────────────────────── +// ── Storefront CRUD ───────────────────────────────────────────── -partnersCommand - .command('list') - .description('List all partners') - .option('--take ', 'Maximum number of results', '50') - .option('--skip ', 'Number of results to skip', '0') - .option('--status ', 'Filter by status (ACTIVE, ARCHIVED)') - .option('--name ', 'Filter by name') - .action(async (options, cmd) => { +storefrontCommand + .command('get') + .description('Get storefront details') + .action(async (_options: unknown, cmd: Command) => { try { const globalOpts = cmd.optsWithGlobals() as GlobalOptions; const client = createClient(globalOpts); - const result = await client.partners.list({ - take: parseInt(options.take, 10), - skip: parseInt(options.skip, 10), - status: options.status, - name: options.name, - }); - + const result = await client.storefront.get(); formatOutput(result, globalOpts.format as OutputFormat); } catch (error) { printError(error instanceof Error ? error.message : 'Unknown error'); @@ -38,67 +28,76 @@ partnersCommand } }); -partnersCommand +storefrontCommand .command('create') - .description('Create a new partner') - .requiredOption('--name ', 'Partner name') - .option('--description ', 'Partner description') + .description('Create a new storefront') + .requiredOption('--platform-id ', 'Platform ID') + .requiredOption('--name ', 'Storefront name') + .option('--publisher-domain ', 'Publisher domain') + .option('--plan ', 'Plan') .action(async (options, cmd) => { try { const globalOpts = cmd.optsWithGlobals() as GlobalOptions; const client = createClient(globalOpts); - const result = await client.partners.create({ + const result = await client.storefront.create({ + platformId: options.platformId, name: options.name, - description: options.description, + publisherDomain: options.publisherDomain, + plan: options.plan, }); formatOutput(result, globalOpts.format as OutputFormat); - printSuccess(`Created partner: ${result.data.id}`); + printSuccess(`Created storefront: ${result.data.platformId}`); } catch (error) { printError(error instanceof Error ? error.message : 'Unknown error'); process.exit(1); } }); -partnersCommand - .command('update ') - .description('Update a partner') +storefrontCommand + .command('update') + .description('Update the storefront') .option('--name ', 'New name') - .option('--description ', 'New description') - .action(async (id: string, options, cmd) => { + .option('--publisher-domain ', 'New publisher domain') + .option('--plan ', 'New plan') + .option('--enabled ', 'Enable or disable') + .action(async (options, cmd) => { try { const globalOpts = cmd.optsWithGlobals() as GlobalOptions; const client = createClient(globalOpts); - const updateData: { name?: string; description?: string } = {}; + const updateData: Record = {}; if (options.name) updateData.name = options.name; - if (options.description) updateData.description = options.description; + if (options.publisherDomain) updateData.publisherDomain = options.publisherDomain; + if (options.plan) updateData.plan = options.plan; + if (options.enabled !== undefined) updateData.enabled = options.enabled === 'true'; if (Object.keys(updateData).length === 0) { printError('No update fields provided'); process.exit(1); } - const result = await client.partners.update(id, updateData); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await client.storefront.update(updateData as any); formatOutput(result, globalOpts.format as OutputFormat); - printSuccess('Partner updated'); + printSuccess('Storefront updated'); } catch (error) { printError(error instanceof Error ? error.message : 'Unknown error'); process.exit(1); } }); -partnersCommand - .command('archive ') - .description('Archive a partner') - .action(async (id: string, _options: unknown, cmd: Command) => { +storefrontCommand + .command('delete') + .description('Delete the storefront') + .action(async (_options: unknown, cmd: Command) => { try { const globalOpts = cmd.optsWithGlobals() as GlobalOptions; const client = createClient(globalOpts); - await client.partners.archive(id); - printSuccess(`Archived partner: ${id}`); + await client.storefront.delete(); + printSuccess('Storefront deleted'); } catch (error) { printError(error instanceof Error ? error.message : 'Unknown error'); process.exit(1); @@ -149,51 +148,6 @@ agentsCommand } }); -agentsCommand - .command('register') - .description('Register a new agent') - .requiredOption('--partner-id ', 'Partner ID') - .requiredOption('--type ', 'Agent type (SALES, SIGNAL, CREATIVE, OUTCOME)') - .requiredOption('--name ', 'Agent name') - .requiredOption('--endpoint-url ', 'Agent endpoint URL') - .requiredOption('--protocol ', 'Protocol (MCP, A2A)') - .requiredOption( - '--account-policy ', - 'Account policy (comma-separated: advertiser_account,marketplace_account)' - ) - .requiredOption('--auth-type ', 'Authentication type (API_KEY, NO_AUTH, JWT, OAUTH)') - .option('--auth-token ', 'Bearer token for API_KEY auth') - .option('--description ', 'Agent description') - .action(async (options, cmd) => { - try { - const globalOpts = cmd.optsWithGlobals() as GlobalOptions; - const client = createClient(globalOpts); - - const data: Record = { - partnerId: options.partnerId, - type: options.type, - name: options.name, - endpointUrl: options.endpointUrl, - protocol: options.protocol, - accountPolicy: options.accountPolicy.split(',').map((p: string) => p.trim()), - authenticationType: options.authType, - description: options.description, - }; - - if (options.authToken) { - data.auth = { type: 'bearer', token: options.authToken }; - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const result = await client.agents.register(data as any); - formatOutput(result, globalOpts.format as OutputFormat); - printSuccess(`Registered agent: ${result.data.agentId}`); - } catch (error) { - printError(error instanceof Error ? error.message : 'Unknown error'); - process.exit(1); - } - }); - agentsCommand .command('update ') .description('Update an agent') @@ -201,7 +155,6 @@ agentsCommand .option('--description ', 'New description') .option('--status ', 'New status (PENDING, ACTIVE, DISABLED)') .option('--endpoint-url ', 'New endpoint URL') - .option('--account-policy ', 'New account policy (comma-separated)') .action(async (agentId: string, options, cmd) => { try { const globalOpts = cmd.optsWithGlobals() as GlobalOptions; @@ -212,9 +165,6 @@ agentsCommand if (options.description) data.description = options.description; if (options.status) data.status = options.status; if (options.endpointUrl) data.endpointUrl = options.endpointUrl; - if (options.accountPolicy) { - data.accountPolicy = options.accountPolicy.split(',').map((p: string) => p.trim()); - } if (Object.keys(data).length === 0) { printError('No update fields provided'); @@ -288,6 +238,6 @@ agentsCommand } }); -partnersCommand.addCommand(agentsCommand); +storefrontCommand.addCommand(agentsCommand); export { agentsCommand }; diff --git a/src/cli/format.ts b/src/cli/format.ts index 9656976..dd59b7b 100644 --- a/src/cli/format.ts +++ b/src/cli/format.ts @@ -84,7 +84,7 @@ function isNestedDataResponse(data: unknown): { 'products', 'advertisers', 'agents', - 'partners', + 'storefronts', ]; for (const key of arrayKeys) { if (key in dataObj && Array.isArray(dataObj[key])) { diff --git a/src/cli/index.ts b/src/cli/index.ts index 12f4baa..6ea055a 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -9,7 +9,7 @@ * Examples: * scope3 config set apiKey sk_xxx * scope3 --persona buyer advertisers list - * scope3 --persona partner partners list + * scope3 --persona storefront storefront get * scope3 campaigns create-discovery --advertiser-id xxx --bundle-id yyy --name "Q1 Campaign" * scope3 bundles create --advertiser-id xxx --channels ctv,display */ @@ -25,7 +25,7 @@ import { creativeSetsCommand, loginCommand, logoutCommand, - partnersCommand, + storefrontCommand, reportingCommand, salesAgentsCommand, } from './commands'; @@ -50,7 +50,7 @@ program .option('--base-url ', 'Custom API base URL') .option('--format ', 'Output format: json, table, or yaml (default: table)') .option('--debug', 'Enable debug mode') - .option('--persona ', 'API persona: buyer or partner (default: buyer)'); + .option('--persona ', 'API persona: buyer or storefront (default: buyer)'); // Warn if the OAuth session token is expired before running any command program.hook('preAction', (_thisCommand, actionCommand) => { @@ -79,7 +79,7 @@ program.addCommand(campaignsCommand); program.addCommand(configCommand); program.addCommand(conversionEventsCommand); program.addCommand(creativeSetsCommand); -program.addCommand(partnersCommand); +program.addCommand(storefrontCommand); program.addCommand(reportingCommand); program.addCommand(salesAgentsCommand); @@ -142,20 +142,21 @@ const commandsCmd = new Command('commands') console.log(' add-asset Add an asset to a creative set'); console.log(' remove-asset Remove an asset from a creative set'); - // Partner persona - console.log(chalk.blue.bold('\n\nPARTNER PERSONA') + chalk.gray(' (use --persona partner)')); - console.log(chalk.gray('For technology partners - manage partners and agents\n')); + // Storefront persona + console.log( + chalk.blue.bold('\n\nSTOREFRONT PERSONA') + chalk.gray(' (use --persona storefront)') + ); + console.log(chalk.gray('For storefronts - manage storefront, inventory sources, and agents\n')); - console.log(chalk.cyan(' partners')); - console.log(' list List all partners'); - console.log(' create Create a new partner'); - console.log(' update Update a partner'); - console.log(' archive Archive a partner'); + console.log(chalk.cyan(' storefront')); + console.log(' get Get storefront details'); + console.log(' create Create a new storefront'); + console.log(' update Update the storefront'); + console.log(' delete Delete the storefront'); - console.log(chalk.cyan('\n partners agents')); + console.log(chalk.cyan('\n storefront agents')); console.log(' list List all agents'); console.log(' get Get agent details'); - console.log(' register Register a new agent'); console.log(' update Update an agent'); console.log(' oauth-authorize Start agent-level OAuth flow'); console.log(' oauth-authorize-account Start per-account OAuth flow'); diff --git a/src/client.ts b/src/client.ts index 4c3dec5..9e34394 100644 --- a/src/client.ts +++ b/src/client.ts @@ -16,8 +16,14 @@ import { BundlesResource } from './resources/bundles'; import { SignalsResource } from './resources/signals'; import { ReportingResource } from './resources/reporting'; import { SalesAgentsResource } from './resources/sales-agents'; -import { PartnersResource } from './resources/partners'; +import { StorefrontResource } from './resources/storefront'; +import { InventorySourcesResource } from './resources/inventory-sources'; import { AgentsResource } from './resources/agents'; +import { ReadinessResource } from './resources/readiness'; +import { BillingResource } from './resources/billing'; +import { NotificationsResource } from './resources/notifications'; +import { TasksResource } from './resources/tasks'; +import { PropertyListChecksResource } from './resources/property-lists'; import { fetchSkillMd, parseSkillMd, ParsedSkill } from './skill'; /** @@ -30,9 +36,9 @@ import { fetchSkillMd, parseSkillMd, ParsedSkill } from './skill'; * const client = new Scope3Client({ apiKey: 'token', persona: 'buyer' }); * const advertisers = await client.advertisers.list(); * - * // Partner persona - * const partnerClient = new Scope3Client({ apiKey: 'token', persona: 'partner' }); - * const partners = await partnerClient.partners.list(); + * // Storefront persona + * const sfClient = new Scope3Client({ apiKey: 'token', persona: 'storefront' }); + * const sf = await sfClient.storefront.get(); * ``` */ export class Scope3Client { @@ -43,10 +49,16 @@ export class Scope3Client { private _signals?: SignalsResource; private _reporting?: ReportingResource; private _salesAgents?: SalesAgentsResource; + private _tasks?: TasksResource; + private _propertyListChecks?: PropertyListChecksResource; - // Partner persona resources - private _partners?: PartnersResource; + // Storefront persona resources + private _storefront?: StorefrontResource; + private _inventorySources?: InventorySourcesResource; private _agents?: AgentsResource; + private _readiness?: ReadinessResource; + private _billing?: BillingResource; + private _notifications?: NotificationsResource; private readonly adapter: RestAdapter; @@ -61,7 +73,7 @@ export class Scope3Client { throw new Error('apiKey is required'); } if (!config.persona) { - throw new Error('persona is required (buyer or partner)'); + throw new Error('persona is required (buyer or storefront)'); } this.version = config.version ?? 'v2'; @@ -76,10 +88,16 @@ export class Scope3Client { this._signals = new SignalsResource(this.adapter); this._reporting = new ReportingResource(this.adapter); this._salesAgents = new SalesAgentsResource(this.adapter); + this._tasks = new TasksResource(this.adapter); + this._propertyListChecks = new PropertyListChecksResource(this.adapter); break; - case 'partner': - this._partners = new PartnersResource(this.adapter); + case 'storefront': + this._storefront = new StorefrontResource(this.adapter); + this._inventorySources = new InventorySourcesResource(this.adapter); this._agents = new AgentsResource(this.adapter); + this._readiness = new ReadinessResource(this.adapter); + this._billing = new BillingResource(this.adapter); + this._notifications = new NotificationsResource(this.adapter); break; default: { const _exhaustive: never = this.persona; @@ -132,22 +150,64 @@ export class Scope3Client { return this._salesAgents; } - // ── Partner persona resources ──────────────────────────────────── + get tasks(): TasksResource { + if (!this._tasks) { + throw new Error('tasks is only available with the buyer persona'); + } + return this._tasks; + } + + get propertyListChecks(): PropertyListChecksResource { + if (!this._propertyListChecks) { + throw new Error('propertyListChecks is only available with the buyer persona'); + } + return this._propertyListChecks; + } + + // ── Storefront persona resources ───────────────────────────────── + + get storefront(): StorefrontResource { + if (!this._storefront) { + throw new Error('storefront is only available with the storefront persona'); + } + return this._storefront; + } - get partners(): PartnersResource { - if (!this._partners) { - throw new Error('partners is only available with the partner persona'); + get inventorySources(): InventorySourcesResource { + if (!this._inventorySources) { + throw new Error('inventorySources is only available with the storefront persona'); } - return this._partners; + return this._inventorySources; } get agents(): AgentsResource { if (!this._agents) { - throw new Error('agents is only available with the partner persona'); + throw new Error('agents is only available with the storefront persona'); } return this._agents; } + get readiness(): ReadinessResource { + if (!this._readiness) { + throw new Error('readiness is only available with the storefront persona'); + } + return this._readiness; + } + + get billing(): BillingResource { + if (!this._billing) { + throw new Error('billing is only available with the storefront persona'); + } + return this._billing; + } + + get notifications(): NotificationsResource { + if (!this._notifications) { + throw new Error('notifications is only available with the storefront persona'); + } + return this._notifications; + } + // ── Shared methods ─────────────────────────────────────────────── async getSkill(): Promise { diff --git a/src/index.ts b/src/index.ts index 13a8481..843e7a2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -51,16 +51,29 @@ export type { BaseAdapter } from './adapters/base'; export { AdvertisersResource, AgentsResource, + BillingResource, BundlesResource, BundleProductsResource, CampaignsResource, ConversionEventsResource, CreativeSetsResource, - PartnersResource, + InventorySourcesResource, + NotificationsResource, + ReadinessResource, ReportingResource, SalesAgentsResource, SignalsResource, + StorefrontResource, TestCohortsResource, + EventSourcesResource, + MeasurementDataResource, + CatalogsResource, + AudiencesResource, + SyndicationResource, + TasksResource, + PropertyListsResource, + PropertyListChecksResource, + CreativesResource, } from './resources'; // ── skill.md support ─────────────────────────────────────────────── @@ -172,18 +185,33 @@ export type { // Signals Signal, DiscoverSignalsInput, - // Partner - Partner, - CreatePartnerInput, - UpdatePartnerInput, - ListPartnersParams, + // Storefront + Storefront, + CreateStorefrontInput, + UpdateStorefrontInput, + // Inventory Sources + InventorySource, + InventorySourceExecutionType, + CreateInventorySourceInput, + UpdateInventorySourceInput, + // Storefront Readiness + ReadinessStatus, + ReadinessCheck, + StorefrontReadiness, + // Storefront Billing + BillingFee, + StorefrontBilling, + StorefrontBillingConfig, + StripeConnectResponse, + // Notifications + Notification, + ListNotificationsParams, // Agent Agent, AgentType, AgentStatus, AgentAuthenticationType, AgentProtocol, - RegisterAgentInput, UpdateAgentInput, ListAgentsParams, OAuthAuthorizeResponse, diff --git a/src/resources/advertisers.ts b/src/resources/advertisers.ts index 5272332..68ac42b 100644 --- a/src/resources/advertisers.ts +++ b/src/resources/advertisers.ts @@ -14,6 +14,12 @@ import type { import { ConversionEventsResource } from './conversion-events'; import { CreativeSetsResource } from './creative-sets'; import { TestCohortsResource } from './test-cohorts'; +import { EventSourcesResource } from './event-sources'; +import { MeasurementDataResource } from './measurement-data'; +import { CatalogsResource } from './catalogs'; +import { AudiencesResource } from './audiences'; +import { SyndicationResource } from './syndication'; +import { PropertyListsResource } from './property-lists'; /** * Resource for managing advertisers (Buyer persona) @@ -112,4 +118,58 @@ export class AdvertisersResource { testCohorts(advertiserId: string): TestCohortsResource { return new TestCohortsResource(this.adapter, validateResourceId(advertiserId)); } + + /** + * Get the event sources resource for a specific advertiser + * @param advertiserId Advertiser ID + * @returns EventSourcesResource scoped to the advertiser + */ + eventSources(advertiserId: string): EventSourcesResource { + return new EventSourcesResource(this.adapter, validateResourceId(advertiserId)); + } + + /** + * Get the measurement data resource for a specific advertiser + * @param advertiserId Advertiser ID + * @returns MeasurementDataResource scoped to the advertiser + */ + measurementData(advertiserId: string): MeasurementDataResource { + return new MeasurementDataResource(this.adapter, validateResourceId(advertiserId)); + } + + /** + * Get the catalogs resource for a specific advertiser + * @param advertiserId Advertiser ID + * @returns CatalogsResource scoped to the advertiser + */ + catalogs(advertiserId: string): CatalogsResource { + return new CatalogsResource(this.adapter, validateResourceId(advertiserId)); + } + + /** + * Get the audiences resource for a specific advertiser + * @param advertiserId Advertiser ID + * @returns AudiencesResource scoped to the advertiser + */ + audiences(advertiserId: string): AudiencesResource { + return new AudiencesResource(this.adapter, validateResourceId(advertiserId)); + } + + /** + * Get the syndication resource for a specific advertiser + * @param advertiserId Advertiser ID + * @returns SyndicationResource scoped to the advertiser + */ + syndication(advertiserId: string): SyndicationResource { + return new SyndicationResource(this.adapter, validateResourceId(advertiserId)); + } + + /** + * Get the property lists resource for a specific advertiser + * @param advertiserId Advertiser ID + * @returns PropertyListsResource scoped to the advertiser + */ + propertyLists(advertiserId: string): PropertyListsResource { + return new PropertyListsResource(this.adapter, validateResourceId(advertiserId)); + } } diff --git a/src/resources/agents.ts b/src/resources/agents.ts index aeeabeb..3aff0a5 100644 --- a/src/resources/agents.ts +++ b/src/resources/agents.ts @@ -1,11 +1,10 @@ /** - * Agents resource for managing partner agents + * Agents resource for discovering and managing agents under storefront */ import { type BaseAdapter, validateResourceId } from '../adapters/base'; import type { Agent, - RegisterAgentInput, UpdateAgentInput, ListAgentsParams, OAuthAuthorizeResponse, @@ -14,7 +13,7 @@ import type { } from '../types'; /** - * Resource for managing agents (Partner persona) + * Resource for managing agents (Storefront persona) */ export class AgentsResource { constructor(private readonly adapter: BaseAdapter) {} @@ -46,15 +45,6 @@ export class AgentsResource { ); } - /** - * Register a new agent under a partner - * @param data Agent registration data - * @returns Registered agent (may include OAuth authorizationUrl) - */ - async register(data: RegisterAgentInput): Promise> { - return this.adapter.request>('POST', '/agents', data); - } - /** * Update an agent's configuration * @param agentId Agent ID diff --git a/src/resources/audiences.ts b/src/resources/audiences.ts new file mode 100644 index 0000000..85e1fc1 --- /dev/null +++ b/src/resources/audiences.ts @@ -0,0 +1,45 @@ +/** + * Audiences resource for managing advertiser audiences + * Scoped to a specific advertiser + */ + +import { type BaseAdapter, validateResourceId } from '../adapters/base'; + +/** + * Resource for managing audiences (scoped to an advertiser) + */ +export class AudiencesResource { + constructor( + private readonly adapter: BaseAdapter, + private readonly advertiserId: string + ) {} + + /** + * Sync audiences for this advertiser + * @param data Audiences sync payload + * @returns Sync result + */ + async sync(data: unknown): Promise { + return this.adapter.request( + 'POST', + `/advertisers/${validateResourceId(this.advertiserId)}/audiences/sync`, + data + ); + } + + /** + * List audiences for this advertiser + * @param params Optional pagination parameters + * @returns List of audiences + */ + async list(params?: { take?: number; skip?: number }): Promise { + return this.adapter.request( + 'GET', + `/advertisers/${validateResourceId(this.advertiserId)}/audiences`, + undefined, + { + params: { take: params?.take, skip: params?.skip }, + } + ); + } +} diff --git a/src/resources/billing.ts b/src/resources/billing.ts new file mode 100644 index 0000000..617139f --- /dev/null +++ b/src/resources/billing.ts @@ -0,0 +1,73 @@ +/** + * Billing resource for managing storefront Stripe Connect billing + */ + +import { type BaseAdapter } from '../adapters/base'; +import type { StorefrontBillingConfig, StripeConnectResponse } from '../types'; + +/** + * Resource for managing billing (Storefront persona) + */ +export class BillingResource { + constructor(private readonly adapter: BaseAdapter) {} + + /** + * Get billing configuration + * @returns Billing config with Stripe Connect details + */ + async get(): Promise { + return this.adapter.request('GET', '/billing'); + } + + /** + * Connect to Stripe via Stripe Connect + * @returns Stripe Connect response with onboarding URL + */ + async connect(): Promise { + return this.adapter.request('POST', '/billing/connect'); + } + + /** + * Get billing status + * @returns Billing status + */ + async status(): Promise { + return this.adapter.request('GET', '/billing/status'); + } + + /** + * List billing transactions + * @param params Pagination parameters + * @returns List of transactions + */ + async transactions(params?: { limit?: number; starting_after?: string }): Promise { + return this.adapter.request('GET', '/billing/transactions', undefined, { + params: { + limit: params?.limit, + starting_after: params?.starting_after, + }, + }); + } + + /** + * List billing payouts + * @param params Pagination parameters + * @returns List of payouts + */ + async payouts(params?: { limit?: number; starting_after?: string }): Promise { + return this.adapter.request('GET', '/billing/payouts', undefined, { + params: { + limit: params?.limit, + starting_after: params?.starting_after, + }, + }); + } + + /** + * Get Stripe onboarding URL + * @returns Onboarding URL details + */ + async onboardingUrl(): Promise { + return this.adapter.request('GET', '/billing/onboard'); + } +} diff --git a/src/resources/campaigns.ts b/src/resources/campaigns.ts index f832e76..889ec9b 100644 --- a/src/resources/campaigns.ts +++ b/src/resources/campaigns.ts @@ -16,6 +16,7 @@ import type { } from '../types'; import { campaignSchemas } from '../schemas/registry'; import { shouldValidateResponse, validateResponse } from '../validation'; +import { CreativesResource } from './creatives'; /** * Resource for managing campaigns (Buyer persona) @@ -172,4 +173,13 @@ export class CampaignsResource { `/campaigns/${validateResourceId(id)}/pause` ); } + + /** + * Get the creatives resource for a specific campaign + * @param campaignId Campaign ID + * @returns CreativesResource scoped to the campaign + */ + creatives(campaignId: string): CreativesResource { + return new CreativesResource(this.adapter, validateResourceId(campaignId)); + } } diff --git a/src/resources/catalogs.ts b/src/resources/catalogs.ts new file mode 100644 index 0000000..bd08228 --- /dev/null +++ b/src/resources/catalogs.ts @@ -0,0 +1,45 @@ +/** + * Catalogs resource for managing advertiser catalogs + * Scoped to a specific advertiser + */ + +import { type BaseAdapter, validateResourceId } from '../adapters/base'; + +/** + * Resource for managing catalogs (scoped to an advertiser) + */ +export class CatalogsResource { + constructor( + private readonly adapter: BaseAdapter, + private readonly advertiserId: string + ) {} + + /** + * Sync catalogs for this advertiser + * @param data Catalogs sync payload + * @returns Sync result + */ + async sync(data: unknown): Promise { + return this.adapter.request( + 'POST', + `/advertisers/${validateResourceId(this.advertiserId)}/catalogs/sync`, + data + ); + } + + /** + * List catalogs for this advertiser + * @param params Optional filter and pagination parameters + * @returns List of catalogs + */ + async list(params?: { type?: string; take?: number; skip?: number }): Promise { + return this.adapter.request( + 'GET', + `/advertisers/${validateResourceId(this.advertiserId)}/catalogs`, + undefined, + { + params: { type: params?.type, take: params?.take, skip: params?.skip }, + } + ); + } +} diff --git a/src/resources/creatives.ts b/src/resources/creatives.ts new file mode 100644 index 0000000..c9825f1 --- /dev/null +++ b/src/resources/creatives.ts @@ -0,0 +1,84 @@ +/** + * Creatives resource for managing campaign creatives + * Scoped to a specific campaign + */ + +import { type BaseAdapter, validateResourceId } from '../adapters/base'; + +/** + * Resource for managing creatives (scoped to a campaign) + */ +export class CreativesResource { + constructor( + private readonly adapter: BaseAdapter, + private readonly campaignId: string + ) {} + + /** + * List creatives for this campaign + * @param params Optional filter and pagination parameters + * @returns List of creatives + */ + async list(params?: { + quality?: string; + search?: string; + take?: number; + skip?: number; + }): Promise { + return this.adapter.request( + 'GET', + `/campaigns/${validateResourceId(this.campaignId)}/creatives`, + undefined, + { + params: { + quality: params?.quality, + search: params?.search, + take: params?.take, + skip: params?.skip, + }, + } + ); + } + + /** + * Get a creative by ID + * @param creativeId Creative ID + * @param preview Whether to include preview data + * @returns Creative details + */ + async get(creativeId: string, preview?: boolean): Promise { + return this.adapter.request( + 'GET', + `/campaigns/${validateResourceId(this.campaignId)}/creatives/${validateResourceId(creativeId)}`, + undefined, + { + params: preview ? { preview: true } : undefined, + } + ); + } + + /** + * Update creative metadata + * @param creativeId Creative ID + * @param data Update data + * @returns Updated creative + */ + async update(creativeId: string, data: unknown): Promise { + return this.adapter.request( + 'PUT', + `/campaigns/${validateResourceId(this.campaignId)}/creatives/${validateResourceId(creativeId)}`, + data + ); + } + + /** + * Delete a creative + * @param creativeId Creative ID + */ + async delete(creativeId: string): Promise { + await this.adapter.request( + 'DELETE', + `/campaigns/${validateResourceId(this.campaignId)}/creatives/${validateResourceId(creativeId)}` + ); + } +} diff --git a/src/resources/event-sources.ts b/src/resources/event-sources.ts new file mode 100644 index 0000000..f539beb --- /dev/null +++ b/src/resources/event-sources.ts @@ -0,0 +1,90 @@ +/** + * Event sources resource for managing advertiser event sources + * Scoped to a specific advertiser + */ + +import { type BaseAdapter, validateResourceId } from '../adapters/base'; + +/** + * Resource for managing event sources (scoped to an advertiser) + */ +export class EventSourcesResource { + constructor( + private readonly adapter: BaseAdapter, + private readonly advertiserId: string + ) {} + + /** + * Sync (bulk upsert) event sources for this advertiser + * @param data Event sources sync payload + * @returns Sync result + */ + async sync(data: unknown): Promise { + return this.adapter.request( + 'POST', + `/advertisers/${validateResourceId(this.advertiserId)}/event-sources/sync`, + data + ); + } + + /** + * List all event sources for this advertiser + * @returns List of event sources + */ + async list(): Promise { + return this.adapter.request( + 'GET', + `/advertisers/${validateResourceId(this.advertiserId)}/event-sources` + ); + } + + /** + * Create a new event source + * @param data Event source creation data + * @returns Created event source + */ + async create(data: unknown): Promise { + return this.adapter.request( + 'POST', + `/advertisers/${validateResourceId(this.advertiserId)}/event-sources`, + data + ); + } + + /** + * Get an event source by ID + * @param id Event source ID + * @returns Event source details + */ + async get(id: string): Promise { + return this.adapter.request( + 'GET', + `/advertisers/${validateResourceId(this.advertiserId)}/event-sources/${validateResourceId(id)}` + ); + } + + /** + * Update an existing event source + * @param id Event source ID + * @param data Update data + * @returns Updated event source + */ + async update(id: string, data: unknown): Promise { + return this.adapter.request( + 'PUT', + `/advertisers/${validateResourceId(this.advertiserId)}/event-sources/${validateResourceId(id)}`, + data + ); + } + + /** + * Delete an event source + * @param id Event source ID + */ + async delete(id: string): Promise { + await this.adapter.request( + 'DELETE', + `/advertisers/${validateResourceId(this.advertiserId)}/event-sources/${validateResourceId(id)}` + ); + } +} diff --git a/src/resources/index.ts b/src/resources/index.ts index 45b9771..9bc8f8e 100644 --- a/src/resources/index.ts +++ b/src/resources/index.ts @@ -4,13 +4,25 @@ export { AdvertisersResource } from './advertisers'; export { AgentsResource } from './agents'; +export { BillingResource } from './billing'; export { BundlesResource } from './bundles'; export { BundleProductsResource } from './products'; export { CampaignsResource } from './campaigns'; export { ConversionEventsResource } from './conversion-events'; export { CreativeSetsResource } from './creative-sets'; -export { PartnersResource } from './partners'; +export { InventorySourcesResource } from './inventory-sources'; +export { NotificationsResource } from './notifications'; +export { ReadinessResource } from './readiness'; export { ReportingResource } from './reporting'; export { SalesAgentsResource } from './sales-agents'; export { SignalsResource } from './signals'; +export { StorefrontResource } from './storefront'; export { TestCohortsResource } from './test-cohorts'; +export { EventSourcesResource } from './event-sources'; +export { MeasurementDataResource } from './measurement-data'; +export { CatalogsResource } from './catalogs'; +export { AudiencesResource } from './audiences'; +export { SyndicationResource } from './syndication'; +export { TasksResource } from './tasks'; +export { PropertyListsResource, PropertyListChecksResource } from './property-lists'; +export { CreativesResource } from './creatives'; diff --git a/src/resources/inventory-sources.ts b/src/resources/inventory-sources.ts new file mode 100644 index 0000000..6ccd5e3 --- /dev/null +++ b/src/resources/inventory-sources.ts @@ -0,0 +1,75 @@ +/** + * Inventory Sources resource for managing storefront inventory sources + */ + +import { type BaseAdapter, validateResourceId } from '../adapters/base'; +import type { + InventorySource, + CreateInventorySourceInput, + UpdateInventorySourceInput, + ApiResponse, +} from '../types'; + +/** + * Resource for managing inventory sources (Storefront persona) + */ +export class InventorySourcesResource { + constructor(private readonly adapter: BaseAdapter) {} + + /** + * List all inventory sources + * @returns List of inventory sources + */ + async list(): Promise { + return this.adapter.request('GET', '/inventory-sources'); + } + + /** + * Get an inventory source by ID + * @param sourceId Inventory source ID + * @returns Inventory source details + */ + async get(sourceId: string): Promise> { + return this.adapter.request>( + 'GET', + `/inventory-sources/${validateResourceId(sourceId)}` + ); + } + + /** + * Create a new inventory source + * @param data Inventory source creation data + * @returns Created inventory source + */ + async create(data: CreateInventorySourceInput): Promise> { + return this.adapter.request>('POST', '/inventory-sources', data); + } + + /** + * Update an inventory source + * @param sourceId Inventory source ID + * @param data Update data + * @returns Updated inventory source + */ + async update( + sourceId: string, + data: UpdateInventorySourceInput + ): Promise> { + return this.adapter.request>( + 'PUT', + `/inventory-sources/${validateResourceId(sourceId)}`, + data + ); + } + + /** + * Delete an inventory source + * @param sourceId Inventory source ID + */ + async delete(sourceId: string): Promise { + await this.adapter.request( + 'DELETE', + `/inventory-sources/${validateResourceId(sourceId)}` + ); + } +} diff --git a/src/resources/measurement-data.ts b/src/resources/measurement-data.ts new file mode 100644 index 0000000..fb0532c --- /dev/null +++ b/src/resources/measurement-data.ts @@ -0,0 +1,29 @@ +/** + * Measurement data resource for syncing advertiser measurement data + * Scoped to a specific advertiser + */ + +import { type BaseAdapter, validateResourceId } from '../adapters/base'; + +/** + * Resource for managing measurement data (scoped to an advertiser) + */ +export class MeasurementDataResource { + constructor( + private readonly adapter: BaseAdapter, + private readonly advertiserId: string + ) {} + + /** + * Sync measurement data for this advertiser + * @param data Measurement data sync payload + * @returns Sync result + */ + async sync(data: unknown): Promise { + return this.adapter.request( + 'POST', + `/advertisers/${validateResourceId(this.advertiserId)}/measurement-data/sync`, + data + ); + } +} diff --git a/src/resources/notifications.ts b/src/resources/notifications.ts new file mode 100644 index 0000000..1e1d0d9 --- /dev/null +++ b/src/resources/notifications.ts @@ -0,0 +1,64 @@ +/** + * Notifications resource for managing storefront notifications + */ + +import { type BaseAdapter, validateResourceId } from '../adapters/base'; +import type { ListNotificationsParams } from '../types'; + +/** + * Resource for managing notifications (Storefront persona) + */ +export class NotificationsResource { + constructor(private readonly adapter: BaseAdapter) {} + + /** + * List notifications + * @param params Filter and pagination parameters + * @returns List of notifications + */ + async list(params?: ListNotificationsParams): Promise { + return this.adapter.request('GET', '/notifications', undefined, { + params: { + unreadOnly: params?.unreadOnly, + brandAgentId: params?.brandAgentId, + types: params?.types, + limit: params?.limit, + offset: params?.offset, + }, + }); + } + + /** + * Mark a notification as read + * @param notificationId Notification ID + */ + async markAsRead(notificationId: string): Promise { + await this.adapter.request( + 'POST', + `/notifications/${validateResourceId(notificationId)}/read` + ); + } + + /** + * Acknowledge a notification + * @param notificationId Notification ID + */ + async acknowledge(notificationId: string): Promise { + await this.adapter.request( + 'POST', + `/notifications/${validateResourceId(notificationId)}/acknowledge` + ); + } + + /** + * Mark all notifications as read + * @param brandAgentId Optional brand agent ID to scope the operation + */ + async markAllAsRead(brandAgentId?: number): Promise { + await this.adapter.request( + 'POST', + '/notifications/read-all', + brandAgentId ? { brandAgentId } : undefined + ); + } +} diff --git a/src/resources/partners.ts b/src/resources/partners.ts deleted file mode 100644 index 7eef1f0..0000000 --- a/src/resources/partners.ts +++ /dev/null @@ -1,66 +0,0 @@ -/** - * Partners resource for managing activation partnerships - */ - -import { type BaseAdapter, validateResourceId } from '../adapters/base'; -import type { - Partner, - CreatePartnerInput, - UpdatePartnerInput, - ListPartnersParams, - ApiResponse, -} from '../types'; - -/** - * Resource for managing partners (Partner persona) - */ -export class PartnersResource { - constructor(private readonly adapter: BaseAdapter) {} - - /** - * List all partners - * @param params Filter and pagination parameters - * @returns List of partners with pagination - */ - async list(params?: ListPartnersParams): Promise { - return this.adapter.request('GET', '/partners', undefined, { - params: { - status: params?.status, - name: params?.name, - take: params?.take, - skip: params?.skip, - }, - }); - } - - /** - * Create a new partner - * @param data Partner creation data - * @returns Created partner - */ - async create(data: CreatePartnerInput): Promise> { - return this.adapter.request>('POST', '/partners', data); - } - - /** - * Update an existing partner - * @param id Partner ID - * @param data Update data - * @returns Updated partner - */ - async update(id: string, data: UpdatePartnerInput): Promise> { - return this.adapter.request>( - 'PUT', - `/partners/${validateResourceId(id)}`, - data - ); - } - - /** - * Archive a partner - * @param id Partner ID - */ - async archive(id: string): Promise { - await this.adapter.request('DELETE', `/partners/${validateResourceId(id)}`); - } -} diff --git a/src/resources/property-lists.ts b/src/resources/property-lists.ts new file mode 100644 index 0000000..b5a8b96 --- /dev/null +++ b/src/resources/property-lists.ts @@ -0,0 +1,107 @@ +/** + * Property lists resource for managing advertiser property lists + * Scoped to a specific advertiser, with top-level check endpoints + */ + +import { type BaseAdapter, validateResourceId } from '../adapters/base'; + +/** + * Resource for managing property lists (scoped to an advertiser) + */ +export class PropertyListsResource { + constructor( + private readonly adapter: BaseAdapter, + private readonly advertiserId: string + ) {} + + /** + * Create a new property list + * @param data Property list creation data + * @returns Created property list + */ + async create(data: unknown): Promise { + return this.adapter.request( + 'POST', + `/advertisers/${validateResourceId(this.advertiserId)}/property-lists`, + data + ); + } + + /** + * List property lists for this advertiser + * @param params Optional filter parameters + * @returns List of property lists + */ + async list(params?: { purpose?: string }): Promise { + return this.adapter.request( + 'GET', + `/advertisers/${validateResourceId(this.advertiserId)}/property-lists`, + undefined, + { + params: { purpose: params?.purpose }, + } + ); + } + + /** + * Get a property list by ID + * @param listId Property list ID + * @returns Property list details + */ + async get(listId: string): Promise { + return this.adapter.request( + 'GET', + `/advertisers/${validateResourceId(this.advertiserId)}/property-lists/${validateResourceId(listId)}` + ); + } + + /** + * Update an existing property list + * @param listId Property list ID + * @param data Update data + * @returns Updated property list + */ + async update(listId: string, data: unknown): Promise { + return this.adapter.request( + 'PUT', + `/advertisers/${validateResourceId(this.advertiserId)}/property-lists/${validateResourceId(listId)}`, + data + ); + } + + /** + * Delete a property list + * @param listId Property list ID + */ + async delete(listId: string): Promise { + await this.adapter.request( + 'DELETE', + `/advertisers/${validateResourceId(this.advertiserId)}/property-lists/${validateResourceId(listId)}` + ); + } +} + +/** + * Resource for property list check operations (top-level, not scoped to an advertiser) + */ +export class PropertyListChecksResource { + constructor(private readonly adapter: BaseAdapter) {} + + /** + * Check domains against property lists + * @param data Domains to check + * @returns Check result + */ + async check(data: { domains: string[] }): Promise { + return this.adapter.request('POST', '/property-lists/check', data); + } + + /** + * Get a property list check report + * @param reportId Report ID + * @returns Check report details + */ + async getReport(reportId: string): Promise { + return this.adapter.request('GET', `/property-lists/reports/${validateResourceId(reportId)}`); + } +} diff --git a/src/resources/readiness.ts b/src/resources/readiness.ts new file mode 100644 index 0000000..43ebba3 --- /dev/null +++ b/src/resources/readiness.ts @@ -0,0 +1,21 @@ +/** + * Readiness resource for checking storefront readiness status + */ + +import { type BaseAdapter } from '../adapters/base'; +import type { StorefrontReadiness } from '../types'; + +/** + * Resource for checking storefront readiness (Storefront persona) + */ +export class ReadinessResource { + constructor(private readonly adapter: BaseAdapter) {} + + /** + * Check storefront readiness + * @returns Readiness status with individual checks + */ + async check(): Promise { + return this.adapter.request('GET', '/readiness'); + } +} diff --git a/src/resources/storefront.ts b/src/resources/storefront.ts new file mode 100644 index 0000000..9f235da --- /dev/null +++ b/src/resources/storefront.ts @@ -0,0 +1,51 @@ +/** + * Storefront resource for managing the storefront profile + */ + +import { type BaseAdapter } from '../adapters/base'; +import type { + Storefront, + CreateStorefrontInput, + UpdateStorefrontInput, + ApiResponse, +} from '../types'; + +/** + * Resource for managing the storefront (Storefront persona) + */ +export class StorefrontResource { + constructor(private readonly adapter: BaseAdapter) {} + + /** + * Get the current storefront profile + * @returns Storefront details + */ + async get(): Promise> { + return this.adapter.request>('GET', ''); + } + + /** + * Create a new storefront + * @param data Storefront creation data + * @returns Created storefront + */ + async create(data: CreateStorefrontInput): Promise> { + return this.adapter.request>('POST', '', data); + } + + /** + * Update the storefront profile + * @param data Update data + * @returns Updated storefront + */ + async update(data: UpdateStorefrontInput): Promise> { + return this.adapter.request>('PUT', '', data); + } + + /** + * Delete the storefront + */ + async delete(): Promise { + await this.adapter.request('DELETE', ''); + } +} diff --git a/src/resources/syndication.ts b/src/resources/syndication.ts new file mode 100644 index 0000000..a7e8c73 --- /dev/null +++ b/src/resources/syndication.ts @@ -0,0 +1,61 @@ +/** + * Syndication resource for managing advertiser content syndication + * Scoped to a specific advertiser + */ + +import { type BaseAdapter, validateResourceId } from '../adapters/base'; + +/** + * Resource for managing syndication (scoped to an advertiser) + */ +export class SyndicationResource { + constructor( + private readonly adapter: BaseAdapter, + private readonly advertiserId: string + ) {} + + /** + * Syndicate a resource for this advertiser + * @param data Syndication request payload + * @returns Syndication result + */ + async syndicate(data: unknown): Promise { + return this.adapter.request( + 'POST', + `/advertisers/${validateResourceId(this.advertiserId)}/syndicate`, + data + ); + } + + /** + * Query syndication status for this advertiser + * @param params Optional filter and pagination parameters + * @returns Syndication status results + */ + async status(params?: { + resourceType?: string; + resourceId?: string; + adcpAgentId?: string; + enabled?: string; + status?: string; + limit?: number; + offset?: number; + }): Promise { + return this.adapter.request( + 'GET', + `/advertisers/${validateResourceId(this.advertiserId)}/syndication-status`, + undefined, + { + params: { + resourceType: params?.resourceType, + resourceId: params?.resourceId, + adcpAgentId: params?.adcpAgentId, + enabled: params?.enabled, + status: params?.status, + limit: params?.limit, + offset: params?.offset, + }, + } + ); + } +} diff --git a/src/resources/tasks.ts b/src/resources/tasks.ts new file mode 100644 index 0000000..2865ed7 --- /dev/null +++ b/src/resources/tasks.ts @@ -0,0 +1,22 @@ +/** + * Tasks resource for checking async task status + * Top-level buyer resource (not scoped to an advertiser) + */ + +import { type BaseAdapter, validateResourceId } from '../adapters/base'; + +/** + * Resource for managing tasks (Buyer persona, top-level) + */ +export class TasksResource { + constructor(private readonly adapter: BaseAdapter) {} + + /** + * Get task status by ID + * @param taskId Task ID + * @returns Task status details + */ + async get(taskId: string): Promise { + return this.adapter.request('GET', `/tasks/${validateResourceId(taskId)}`); + } +} diff --git a/src/skill/bundled.ts b/src/skill/bundled.ts index c800d23..fab6574 100644 --- a/src/skill/bundled.ts +++ b/src/skill/bundled.ts @@ -25,6 +25,25 @@ This API enables AI-powered programmatic advertising with inventory discovery, c **Important**: This is a REST API accessed via the \`api_call\` tool. After reading this documentation, use \`api_call\` to make HTTP requests to the endpoints below. +## ⚠️ CRITICAL: Presentation Rules + +**Tool responses return JSON data.** For most endpoints (advertisers, sales agents, campaigns, etc.), YOU are responsible for presenting the data clearly in your message. Follow the Display Requirements for each endpoint — they tell you exactly what fields to show and how to structure the output. Never summarize into vague prose — always show the specific data points listed in the display requirements for each item. + +**Exception: Product discovery and reporting endpoints render interactive UI components.** When those tools return a UI, display it as-is — do not generate your own competing visualization. + +## ⚠️ CRITICAL: Always Use ask_about_capability Before api_call + +**Before making ANY \`api_call\`, you MUST first call \`ask_about_capability\`** to learn the correct endpoint, field names, query parameters, and **display requirements**. Do NOT guess or assume any API details — always verify first. + +**Required workflow for every API operation:** +1. Call \`ask_about_capability\` with a question about what you want to do (e.g., "How do I list sales agents and what should I show?", "How do I list advertisers?", "How do I create a campaign?") +2. Read the response to learn the exact endpoints, parameters, field names, **and what information you must present to the user** +3. Only then call \`api_call\` with the verified information + +**Never skip this step.** Even for simple GET requests like listing sales agents or advertisers, always check \`ask_about_capability\` first. Responses contain critical fields (account status, credential requirements, linked accounts) that you MUST present to the user — not just names. + +**Common mistake:** Listing sales agents or advertisers and only showing names. This is WRONG. You must show account status, credential requirements, linked accounts, and other operational details for each item. + ## ⚠️ CRITICAL: Exact Field Names Required **DO NOT GUESS FIELD NAMES.** Use these exact camelCase names: @@ -32,7 +51,10 @@ This API enables AI-powered programmatic advertising with inventory discovery, c | Field | Type | Notes | |-------|------|-------| | \`advertiserId\` | string | NOT \`advertiser_id\` | -| \`brandDomain\` | string | Required on advertiser create (e.g., \`"nike.com"\`) | +| \`brand\` | string | Required on advertiser create (e.g., \`"nike.com"\`) | +| \`saveBrand\` | boolean | Optional on advertiser create. Set \`true\` to save an enriched brand to the registry | +| \`sandbox\` | boolean | Optional on advertiser create. When \`true\`, all ADCP operations use sandbox accounts (no real spend). Immutable after creation | +| \`optimizationApplyMode\` | string | \`"AUTO"\` or \`"MANUAL"\` (default \`"MANUAL"\`). Controls whether Scope3 AI model optimizations to media buys are applied automatically or require manual approval. Set on advertiser (default for all campaigns) or override per campaign. | | \`flightDates\` | **object** | NOT \`startDate\`/\`endDate\` at root level | | \`flightDates.startDate\` | string | ISO 8601: \`"2026-02-05T00:00:00Z"\` | | \`flightDates.endDate\` | string | ISO 8601: \`"2026-02-10T23:59:59Z"\` | @@ -41,7 +63,7 @@ This API enables AI-powered programmatic advertising with inventory discovery, c | \`budget.currency\` | string | \`"USD"\` (default) | | \`constraints\` | object | Optional | | \`constraints.channels\` | array | e.g., \`["display"]\`, \`["ctv"]\` | -| \`performanceConfig\` | object | Required for \`type: "performance"\` | +| \`performanceConfig\` | object | Optional. Contains \`optimizationGoals\` array. Each goal has \`kind\` (\`"event"\` or \`"metric"\`). Event goals have \`eventSources\` array + optional \`target\`. Metric goals have \`metric\` + optional \`target\`. | ## ⚠️ CRITICAL: Always Send the ENTIRE Client Brief @@ -54,7 +76,7 @@ The brief is used by sales agents and the discovery system to match relevant inv - **Copy the full brief verbatim** — include every detail the client provided - **Never summarize** — "Premium CTV for tech enthusiasts" is NOT an acceptable substitute for a multi-paragraph brief - **Never truncate** — if the client gave you 500 words, send all 500 words -- **Applies everywhere \`brief\` is used** — bundle creation, product discovery, campaign creation, and any other endpoint that accepts a brief +- **Applies everywhere \`brief\` is used** — product discovery, campaign creation, and any other endpoint that accepts a brief ## ⚠️ CRITICAL: Never Fabricate User Data @@ -64,12 +86,21 @@ The brief is used by sales agents and the discovery system to match relevant inv - **NEVER fabricate values** for required fields. If the user hasn't provided a value, ask for it. - **Read-only calls are fine** — you can freely call GET endpoints to fetch data and present it to the user. - **Confirm before mutating** — Before any POST, PUT, or DELETE call, verify you have user-provided (or user-confirmed) values for all required fields. -- **Inferring is OK when obvious** — If the user says "optimize for return on ad spend", you can infer \`objective: "ROAS"\`. But if intent is ambiguous, ask. -- **Never make up IDs** — IDs (advertiserId, bundleId, campaignId, etc.) must come from previous API responses or the user. Never generate them. +- **Inferring is OK when obvious** — If the user says "optimize for purchases with a 4x ROAS target", you can infer an event goal with \`eventType: "purchase"\` and \`target: { kind: "per_ad_spend", value: 4.0 }\`. But if intent is ambiguous, ask. +- **Never make up IDs** — IDs (advertiserId, discoveryId, campaignId, etc.) must come from previous API responses or the user. Never generate them. +- **Account IDs for linking MUST come from discovery** — When linking an agent account to an advertiser, the \`accountId\` MUST come from the \`GET /advertisers/{id}/accounts/available?partnerId={agentId}\` response. Even if the user tells you an account ID or name (e.g., "the account is named XYZ" or "the ID is 06cd7033..."), you MUST still call the discovery endpoint and use the \`accountId\` from the API response. If the account doesn't appear in the discovery results, it cannot be linked — tell the user it was not found. NEVER pretend to successfully link an account that was not returned by the discovery endpoint. - **Only use what's documented** — Do NOT invent endpoints, fields, query parameters, or enum values that are not explicitly listed in this skill document. If you're unsure whether something exists, check this document first. If it's not here, don't use it. --- +## Notifications + +The \`help\` and \`ask_about_capability\` tools include unread notifications in their responses. When a response contains a "Unread Notifications" section, summarize those notifications for the user before answering their question. + +Notifications can be listed, marked as read, or acknowledged via the \`/api/v2/buyer/notifications\` endpoints — see the Notifications section below for details. + +**Setup:** To receive notifications proactively at the start of every session, add this to your Claude Desktop Project instructions, CLAUDE.md, or system prompt: \`When using Scope3 tools, always start by calling the help tool. The response includes unread notifications — summarize those for the user before answering their question.\` + ## Quick Start 1. **Use \`ask_about_capability\` first**: Ask about the user's request to learn the correct workflow, endpoints, and field names @@ -79,30 +110,31 @@ The brief is used by sales agents and the discovery system to match relevant inv --- -## Campaign Workflow Decision Tree +## Campaigns -**There is no generic campaign creation endpoint.** You must choose a campaign type first, then follow the type-specific workflow. +Create an ad campaign via \`POST /campaigns\`. Campaigns are configured at creation or update time with: +- **Products**: Select products via the discovery endpoints (\`POST /discovery/discover-products\`, \`POST /discovery/{id}/products\`), then attach it via \`discoveryId\` at campaign creation or update. +- **Performance optimization**: Set via the \`performanceConfig\` field at campaign creation (\`POST /campaigns\`) or update (\`PUT /campaigns/{id}\`). +- **Audience targeting**: Target or suppress audiences via the \`audienceConfig\` field at campaign creation or update. Audiences are synced to the advertiser first via \`POST /audiences/sync\`. +- **Auto-select products (pick for me)**: Use \`POST /campaigns/{campaignId}/auto-select-products\` to let the system automatically choose products and allocate budget using AI. Requires a performance campaign with discovered products. -**CRITICAL: When a user says "create a campaign" without specifying a type, ALWAYS ask them to choose. Do NOT default to any type.** +**⚠️ HARD RULE: One API Call Per Turn** -Match the user's **intent** to the right type: +Only make ONE mutating or discovery API call per turn. After that call, present the results and END YOUR TURN. Do not paginate, re-discover, or chain additional calls — wait for the user to tell you what to do next. -| User Intent | Campaign Type | Workflow | -|-------------|---------------|----------| -| "browse inventory", "show me what's available", "I want to pick publishers", "select products", "specific sites", "curated", "what inventory do you have" | **Discovery** ✅ | See Discovery Campaign Workflow below | -| "optimize for ROAS", "maximize conversions", "drive sales", "hit a CPA target", "performance goal", "optimize my spend" | **Performance** ✅ | See Performance Campaign Workflow below | -| "target tech enthusiasts", "reach parents aged 25-40", "audience segments", "demographic targeting" | **Audience** ❌ Coming soon | Returns 501 — suggest discovery or performance instead | +**Required flow when user says "create a campaign":** +1. Collect campaign details: name, advertiser, budget, flight dates, brief, and any targeting constraints +2. Ask whether they want to target or suppress any audiences. If yes, list the advertiser's audiences (\`GET /advertisers/{accountId}/audiences\`) and let them choose. If the advertiser has no audiences, let the user know and offer: "You don't have any audiences synced yet. I can help you sync audiences to Scope3 — just ask me how to get started." +3. Ask if they want to attach a catalog — if yes, list their catalogs via \`GET /advertisers/{advertiserId}/catalogs\` and let them pick one +4. Ask how they'd like to configure: browse products (discovery) or set performance metrics +5. Based on their choice: + - **Products**: Run discovery ONCE, present results, END YOUR TURN. The user drives what happens next. + - **Performance**: Create the campaign with \`performanceConfig\` +6. Include \`audienceConfig\` if the user selected audiences in step 2 +7. When ready, launch: \`POST /campaigns/{id}/execute\` -**Key distinction:** Does the user want to choose the inventory themselves (→ discovery) or let the system choose (→ performance)? If the user mentions wanting to see products or pick publishers, that's **discovery**, even if they also mention performance goals. - -**When the user's intent is ambiguous**, ask: -> "What type of campaign would you like to create? -> - **Discovery**: You browse and select the specific ad inventory/products -> - **Performance**: The system automatically optimizes for your goal (ROAS, conversions, etc.) -> - **Audience**: Target specific audience segments (coming soon)" - -**All campaign types share these required fields:** -- \`advertiserId\` (string) — NOT \`advertiser_id\` +**Required fields for campaign creation:** +- \`advertiserId\` (number) — NOT \`advertiser_id\` - \`name\` (string) - \`flightDates\` (object) — NOT \`startDate\`/\`endDate\` at root level - \`flightDates.startDate\` (ISO 8601 datetime) @@ -111,74 +143,115 @@ Match the user's **intent** to the right type: - \`budget.total\` (number) - \`budget.currency\` (string, default \`"USD"\`) -Each type then requires additional fields: -- **Discovery**: \`bundleId\` (from \`POST /bundles\`) → create via \`POST /campaigns/discovery\` -- **Performance**: \`performanceConfig.objective\` → create via \`POST /campaigns/performance\` -- **Audience**: \`signals\` → create via \`POST /campaigns/audience\` (coming soon) +**Optional fields at creation:** +- \`discoveryId\`: Attach an existing discovery session +- \`productIds\`: Product IDs to pre-select (requires discoveryId) +- \`performanceConfig\`: For performance optimization. Contains \`optimizationGoals\` array. Each goal has \`kind\` (\`"event"\` or \`"metric"\`). Event goals have \`eventSources\` array (each with \`eventSourceId\`, \`eventType\`, optional \`valueField\`) + optional \`target\` object (\`kind: "per_ad_spend"\` or \`kind: "cost_per"\` with \`value\`). Metric goals have \`metric\` string + optional \`target\`. Goals can include \`attributionWindow\` and \`priority\`. +- \`optimizationApplyMode\`: \`"AUTO"\` or \`"MANUAL"\` (default). Controls whether Scope3 AI model optimizations to media buys are applied automatically or require manual approval. Overrides the advertiser-level default. +- \`catalogId\` (number, optional): Attach a single catalog to the campaign. Only **one** catalog per campaign. The catalog must belong to the same advertiser. Get available catalogs via \`GET /advertisers/{advertiserId}/catalogs\`. When attached, the catalog is automatically included in product discovery requests — referenced by ID for agents that have the catalog syndicated, or sent inline (feed URL or items) otherwise. +- \`constraints.channels\`: Target channels (display, olv, ctv, social) +- \`constraints.countries\`: Target countries (ISO 3166-1 alpha-2 codes) +- \`brief\`: Campaign brief. **MUST be the ENTIRE brief from the client — never summarize or truncate.** +- \`audienceConfig\`: Audience targeting and suppression. Contains \`targetAudienceIds\` (audiences to **include**) and \`suppressAudienceIds\` (audiences to **exclude**). Audience IDs come from \`GET /advertisers/{accountId}/audiences\`. --- -## Browsing Products Without a Campaign - -Browsing products is part of the **discovery** campaign workflow. If a user starts browsing inventory, they are implicitly heading toward a discovery campaign (where they select specific products). This is different from a performance campaign, where the system selects inventory automatically. +## Browsing Products Before Creating a Campaign -**When a user wants to browse products without mentioning a campaign:** +**When a user wants to browse products but hasn't created a campaign yet:** -Users may want to explore available inventory before committing to a campaign. Use \`POST /bundles/discover-products\` which: -- Creates a bundle automatically if no bundleId is provided -- Discovers products based on the advertiser's context -- Returns both the bundleId and discovered products +Users may want to explore available inventory before committing to a campaign. Use \`POST /discovery/discover-products\` which discovers products based on the advertiser's context and returns a \`discoveryId\` along with the discovered products. **Interactive flow:** -1. **Browse products** — Call \`POST /bundles/discover-products\` with advertiser context - - Returns bundleId (auto-created if needed) and product groups - - Save the bundleId for later use +1. **Discover products** — Call \`POST /discovery/discover-products\` with advertiser context + - Returns \`discoveryId\` and product groups — save the \`discoveryId\` for later use 2. **Present products** — Show available inventory in a user-friendly way -3. **Add products to the bundle** — When the user likes products, add them via \`POST /bundles/{id}/products\` -4. **Create campaign later** — When ready, create a campaign with the bundleId via \`POST /campaigns/discovery\` +3. **Select products** — When the user likes products, add them via \`POST /discovery/{id}/products\` +4. **Attach to a campaign** — Create a campaign with the \`discoveryId\` via \`POST /campaigns\`, or attach it to an existing campaign via \`PUT /campaigns/{id}\` with \`discoveryId\` **Request Parameters (Filtering):** - \`publisherDomain\` (optional): Filter products by publisher domain (exact domain component match). Example: "example" matches "example.com", "www.example.com" but "exampl" does not match - \`salesAgentIds\` (optional, array): Filter products by exact sales agent ID(s). Use when you have agent IDs from a previous response. - \`salesAgentNames\` (optional, array): Filter products by sales agent name(s) (case-insensitive match). Use when a user mentions specific sellers, partners, or exchanges by name. +- \`pricingModel\` (optional): Filter by pricing model (\`cpm\`, \`vcpm\`, \`cpc\`, \`cpcv\`, \`cpv\`, \`cpp\`, \`flat_rate\`). Use when a user wants inventory with a specific pricing type. + +See the Campaign Workflow below for the full step-by-step with HTTP examples. + +--- + +## Adding Products to a Campaign + +**When the user wants to choose specific inventory:** + +Product discovery and selection is done via the discovery endpoints. Discover products, select the ones you want, then attach them to the campaign. + +1. **Discover products** — Use \`POST /discovery/discover-products\` + - Show product groups, publishers, channels, and price ranges in a user-friendly way +2. **Present results and let the user choose** — Show the discovered products and ask which ones they want to add. Do NOT auto-select products for the user. +3. **Add their selections** — Call \`POST /discovery/{id}/products\` with the products the user chose + - Show the updated product list and budget allocation +4. **Attach to campaign** — Create the campaign with \`discoveryId\` via \`POST /campaigns\`, or update an existing campaign via \`PUT /campaigns/{id}\` with \`discoveryId\` +5. **Confirm readiness** — "Your campaign has X products selected with $Y allocated. Ready to launch?" +6. **Launch** — Call \`POST /campaigns/{id}/execute\` + +See the Discovery Workflow (Pre-Campaign Product Discovery) section below for the full step-by-step with HTTP examples. + +### Setting Performance Optimization + +**When the user wants the system to optimize for business outcomes:** + +1. **Check conversion events** — Call \`GET /advertisers/{advertiserId}/events/summary\` with \`eventType: "conversion"\` to see what events are available for optimization + - If none exist, help the user configure event sources first + - **Note:** Event data is aggregated hourly. Newly reported events may take up to 1 hour to appear in the summary. +2. **Set performance config** — Include \`performanceConfig\` at campaign creation (\`POST /campaigns\`) or update (\`PUT /campaigns/{id}\`) + - Required: \`optimizationGoals\` array with at least one goal object + - Each goal has \`kind\` (\`"event"\` or \`"metric"\`) + - Event goals: \`eventSources\` array (each with \`eventSourceId\`, \`eventType\`, optional \`valueField\`), optional \`target\` (\`kind: "per_ad_spend"\` or \`kind: "cost_per"\` with \`value\`), optional \`attributionWindow\`, optional \`priority\` + - Metric goals: \`metric\` string, optional \`target\`, optional \`priority\` +3. **Launch** — Call \`POST /campaigns/{id}/execute\` + +### Auto-Selecting Products (Pick For Me) + +**When the user wants the system to choose products automatically:** + +Instead of manually browsing and selecting products, performance campaigns can use auto-selection: -**Key benefit:** Users can explore inventory without the overhead of creating bundles manually. The bundleId is returned so they can continue building their selection. +1. **Ensure products are discovered** — The campaign must have discovered products (via \`POST /discovery/discover-products\` or auto-discovery at campaign creation with \`performanceConfig\` + \`constraints.channels\`) +2. **Auto-select** — Call \`POST /campaigns/{campaignId}/auto-select-products\` (no request body needed) + - The system uses AI to select the best products based on the campaign brief, budget, constraints, and optimization goals + - Budget is allocated across selected products based on strategic fit + - Any previous product selections in the discovery session are replaced +3. **Review selections** — Present the selected products, budget allocations, and rationale to the user +4. **Refine (optional)** — The user can adjust selections using existing discovery endpoints: + - \`POST /discovery/{discoveryId}/products\` — Add products + - \`DELETE /discovery/{discoveryId}/products\` — Remove products + - Or call \`POST /campaigns/{campaignId}/auto-select-products\` again to re-select +5. **Launch** — When the user confirms, call \`POST /campaigns/{campaignId}/execute\` -See the Discovery Campaign Workflow below for the full step-by-step with HTTP examples. +**Response includes:** +- \`selectedProducts\`: Array of products with budget allocations +- \`budgetContext\`: Campaign budget vs allocated amount +- \`selectionRationale\`: AI-generated explanation of the selection strategy +- \`selectionMethod\`: \`"scoring"\`, \`"measurability"\`, or \`"cpm_heuristic"\` +- \`testBudgetPerProduct\`: Test budget allocated per product (when using measurability or scoring strategy) +- \`productCount\`: Number of products selected --- -## Discovery Campaign Workflow +## Discovery Workflow (Pre-Campaign Product Discovery) -**When to use:** User wants to browse, select, or control which specific inventory/products to include. +**When to use:** User wants to browse, select, or control which specific inventory/products to include before or independently of campaign creation. -**Prerequisites:** Advertiser exists with a linked brand (set during advertiser creation via \`brandDomain\`). +**Prerequisites:** Advertiser exists with a linked brand (set during advertiser creation via \`brand\`). ### Interactive Flow -Follow these steps in order. **Do NOT skip bundle creation or product discovery.** +Follow these steps in order. **Do NOT skip product discovery.** -**Step 1: Create the bundle** +**Step 1: Discover products** \`\`\`http -POST /api/v2/buyer/bundles -{ - "advertiserId": "12345", - "channels": ["ctv", "display"], - "countries": ["US", "CA"], - "brief": "<<< ALWAYS include the ENTIRE brief from the client here — never summarize >>>", - "budget": 50000, - "flightDates": { - "startDate": "2025-02-01T00:00:00Z", - "endDate": "2025-03-31T23:59:59Z" - } -} -\`\`\` -→ Returns \`{ "bundleId": "abc123-def456-ghi789" }\` — save this for all subsequent steps. - -Alternatively, use \`POST /bundles/discover-products\` which creates a bundle AND discovers products in one call (useful when a user just wants to browse without explicit bundle creation): -\`\`\`http -POST /api/v2/buyer/bundles/discover-products +POST /api/v2/buyer/discovery/discover-products { "advertiserId": "12345", "channels": ["ctv", "display"], @@ -187,23 +260,30 @@ POST /api/v2/buyer/bundles/discover-products "salesAgentNames": ["Acme Ad Exchange"] } \`\`\` -→ Returns \`{ "bundleId": "...", "productGroups": [...], "totalGroups": 25, "hasMoreGroups": true, "summary": { ... } }\` +→ Returns \`{ "discoveryId": "...", "productGroups": [...], "totalGroups": 25, "hasMoreGroups": true, "summary": { ... } }\` -**Step 2: STOP and guide the user** +Save the \`discoveryId\` for all subsequent steps. -Do NOT immediately create the campaign. Instead: -- Explain that you've started building their campaign bundle -- Offer to show them available inventory: "Would you like me to show you the available inventory?" +**Step 2: Present results and END YOUR TURN** -**Step 3: Discover products** - -If you used \`POST /bundles\` in Step 1 (not \`POST /bundles/discover-products\`), discover products now: +Present the discovered products and END YOUR TURN. If \`hasMoreGroups: true\`, tell the user more are available. +To browse more products or apply filters, use: \`\`\`http -GET /api/v2/buyer/bundles/{bundleId}/discover-products?groupLimit=10&groupOffset=0&productsPerGroup=5 +GET /api/v2/buyer/discovery/{discoveryId}/discover-products?groupLimit=10&groupOffset=0&productsPerGroup=15 \`\`\` -Present product groups, publishers, channels, and price ranges in a user-friendly way. +**When discovery returns no products:** + +A discovery response with 0 products is **not an error** — it means no products matched. Do NOT say "discovery failed." Do NOT speculate about why (e.g. "likely because X wasn't set up correctly" — you have no idea why, and guessing will be wrong and confusing). Just state the fact and offer these specific next steps: + +1. **Add specificity** — Include budget, flight dates, specific channels (e.g. CTV, display), or audience targeting. Richer briefs give agents more to match against. +2. **Try different channels or geos** — The available inventory may not cover the requested combination. +3. **Reduce the ask** — If the brief is very narrow (e.g. a niche audience + specific publisher + tight budget), broadening one or more constraints often unlocks results. +4. **Try specific filters** — Filter by \`salesAgentNames\` or \`publisherDomain\` to target sellers known to have relevant inventory. + +Example response when no products are returned: +> No products were returned for this brief. A few things that might help: adding a budget or flight dates, specifying channels (CTV, display, etc.), broadening the audience, or filtering by a specific seller. Want to try refining the brief? **Explaining product relevance (IMPORTANT):** @@ -229,6 +309,8 @@ When discovering products, these filters narrow results before grouping and pagi - Example: "hulu" matches "hulu.com", "www.hulu.com" but "hul" does not - \`salesAgentIds\`: Filter by exact sales agent ID(s). Use when you have agent IDs from a previous response. Accepts multiple values. - \`salesAgentNames\`: Filter by sales agent name(s) (case-insensitive substring match). Use when a user mentions specific sellers, partners, or exchanges by name. Accepts multiple values. +- \`pricingModel\`: Filter by pricing model. Use when a user asks about specific pricing types. + - Valid values: \`cpm\`, \`vcpm\`, \`cpc\`, \`cpcv\`, \`cpv\`, \`cpp\`, \`flat_rate\` Filters can be combined. Multiple values within a filter use OR logic (match any); different filters use AND logic. @@ -239,20 +321,24 @@ Do NOT mention filter parameter names. Respond naturally: - User: "Show me [publisher] inventory" → filter by publisherDomain - User: "What does [agent name] have on [publisher]?" → filter by both salesAgentNames and publisherDomain - User: "Show me inventory from [agent 1] and [agent 2]" → filter by salesAgentNames with both values +- User: "Show me CPM inventory" → filter by pricingModel=cpm +- User: "What flat rate options are there?" → filter by pricingModel=flat_rate - User: "Who sells CTV inventory?" → show unfiltered results, then offer to narrow by seller Each product group represents a sales agent. To focus on a specific agent's inventory, use the sales agent filter on subsequent requests. -**Step 4: Select products interactively** +**Step 3: Select products interactively** Users can select products in two ways: -1. **Via the interactive card UI** — Users select product cards and click "Add to Bundle". When this happens, you'll receive a message containing the bundleId, productIds, and the exact \`POST /bundles/{id}/products\` API call to execute. The message will also ask you to prompt the user about per-product budgets. **Ask the user if they'd like to set individual budgets before executing the API call.** If they provide budgets, add a \`budget\` field to each product in the request body. If they decline, execute the call as-is without budgets. Do not re-discover products or look up IDs. +1. **Via the interactive card UI** — Users select product cards and click "Select". When this happens, you'll receive a message containing the discoveryId, productIds, and the exact \`POST /discovery/{id}/products\` API call to execute. The message will also ask you to prompt the user about per-product budgets. **Ask the user if they'd like to set individual budgets before executing the API call.** If they provide budgets, add a \`budget\` field to each product in the request body. If they decline, execute the call as-is without budgets. Do not re-discover products or look up IDs. 2. **Via conversation** — Users describe which products they want in natural language. The productId, salesAgentId, groupId, and groupName are included in the product listing from the discovery response — extract them from there to build the API call. Do not re-discover products to obtain IDs. **Per-product budget:** Each product supports an optional \`budget\` field (number, in dollars). Ask about budgets before adding products — don't assume a budget if the user hasn't specified one. +**Bid price (REQUIRED for non-fixed pricing):** When a product's selected pricing option has \`isFixed: false\`, you MUST include \`bidPrice\` in the request body. Use the \`rate\` or \`floorPrice\` from the product's \`pricingOptions\` (from the discovery response) as the \`bidPrice\` value — do NOT ask the user for this. If \`isFixed: true\`, omit \`bidPrice\`. + \`\`\`http -POST /api/v2/buyer/bundles/{bundleId}/products +POST /api/v2/buyer/discovery/{discoveryId}/products { "products": [ { @@ -260,27 +346,27 @@ POST /api/v2/buyer/bundles/{bundleId}/products "salesAgentId": "agent_456", "groupId": "ctx_123-group-0", "groupName": "Publisher Name", - "cpm": 12.50, + "bidPrice": 12.50, "budget": 5000 } ] } \`\`\` -Show the updated bundle with selected products and budget allocation. +Show the updated selection with selected products and budget allocation. -**Step 5: Confirm readiness** +**Step 4: Confirm readiness** -"Your bundle has X products selected with $Y allocated. Ready to create the campaign?" +"You have selected X products with $Y allocated. Ready to create the campaign?" -**Step 6: Create the campaign** +**Step 5: Create the campaign** \`\`\`http -POST /api/v2/buyer/campaigns/discovery +POST /api/v2/buyer/campaigns { "advertiserId": "12345", "name": "Q1 2025 CTV Campaign", - "bundleId": "abc123-def456-ghi789", + "discoveryId": "abc123-def456-ghi789", "flightDates": { "startDate": "2025-02-01T00:00:00Z", "endDate": "2025-03-31T23:59:59Z" @@ -292,89 +378,65 @@ POST /api/v2/buyer/campaigns/discovery } \`\`\` -**Step 7: Launch** +**Step 6: Launch** \`\`\`http POST /api/v2/buyer/campaigns/{campaignId}/execute \`\`\` -### Bundle Management - -- \`GET /bundles/{id}/products\` — List selected products -- \`POST /bundles/{id}/products\` — Add products -- \`DELETE /bundles/{id}/products\` — Remove products (body: \`{ "productIds": ["..."] }\`) - -**Summary:** Create bundle → Discover products → Select products → Create campaign → Execute - -### Bundle Lifecycle +### Discovery Management -**How long do bundles live?** -- Bundles persist indefinitely until explicitly completed -- There is no automatic expiration or TTL -- Bundles remain in "active" status until a campaign is executed +- \`GET /discovery/{id}/products\` — List selected products +- \`POST /discovery/{id}/products\` — Add products +- \`DELETE /discovery/{id}/products\` — Remove products (body: \`{ "productIds": ["..."] }\`) -**Can bundles be reused?** -- Yes, the same bundleId can be attached to multiple campaigns -- Each campaign independently references the bundle's products -- Modifying the bundle affects all campaigns that reference it - -**What happens after campaign creation?** -- The bundle remains active after campaign creation -- You can continue adding/removing products from the bundle -- The bundle is only "completed" when the campaign is executed -- Once completed, the bundle cannot be modified - -**Best Practices:** -- Create one bundle per campaign workflow -- Complete product selection before creating the campaign -- Don't reuse bundles across unrelated campaigns +**Summary:** Discover products → Select products → Create campaign → Execute **IMPORTANT:** Do NOT expose API details to the user. Communicate conversationally about campaigns, inventory, products, and budgets — not about endpoints or HTTP methods. --- -## Performance Campaign Workflow +## Performance Optimization Workflow -**When to use:** User wants the system to optimize for business outcomes automatically. The system handles inventory selection — no manual product or signal selection needed. +**When to use:** User wants the system to optimize for business outcomes automatically. -**Prerequisites:** Advertiser exists (with brand set during creation) + Conversion events configured. - -### Steps +**Prerequisites:** Advertiser exists (with brand set during creation) + Event source configured. **Step 1: Verify advertiser** - \`\`\`http GET /api/v2/buyer/advertisers?status=ACTIVE&name={advertiserName} \`\`\` -**Step 2: Check/create conversion events** +**Step 2: Check/create event source** \`\`\`http -GET /api/v2/buyer/advertisers/{advertiserId}/conversion-events +GET /api/v2/buyer/advertisers/{advertiserId}/event-sources \`\`\` If empty, create one: \`\`\`http -POST /api/v2/buyer/advertisers/{advertiserId}/conversion-events +POST /api/v2/buyer/advertisers/{advertiserId}/event-sources { - "name": "Purchase", - "type": "PURCHASE" + "eventSourceId": "website_pixel", + "name": "Website Pixel", + "eventTypes": ["purchase", "add_to_cart"] } \`\`\` +Save the \`eventSourceId\` — it's required for optimization goals. + **Step 3: Gather required fields from the user** Before calling the create endpoint, confirm you have: - Campaign name (ask the user or confirm a suggested name) - Flight dates (start and end — ask the user) - Budget total and currency (ask the user) -- Performance objective: \`ROAS\`, \`CONVERSIONS\`, \`LEADS\`, or \`SALES\` (ask the user if not clear from context) -- Optional: goals, bid strategy, constraints (offer these but don't require) - -**Step 4: Create the campaign** +- Optimization goals: at least one goal with \`kind\` ("event" or "metric"). For event goals: event source ID (from Step 2), event type (e.g. \`purchase\`, \`lead\`), and optionally a \`target\` (e.g. \`kind: "per_ad_spend"\` for ROAS or \`kind: "cost_per"\` for CPA). For metric goals: \`metric\` string and optional \`target\`. +- Optional: constraints, attribution window, priority +**Step 4: Create the campaign with performanceConfig** \`\`\`http -POST /api/v2/buyer/campaigns/performance +POST /api/v2/buyer/campaigns { "advertiserId": "12345", "name": "Q1 ROAS Optimization", @@ -387,10 +449,15 @@ POST /api/v2/buyer/campaigns/performance "currency": "USD" }, "performanceConfig": { - "objective": "ROAS", - "goals": { - "targetRoas": 4.0 - } + "optimizationGoals": [{ + "kind": "event", + "eventSources": [ + { "eventSourceId": "es_abc123", "eventType": "purchase", "valueField": "value" } + ], + "target": { "kind": "per_ad_spend", "value": 4.0 }, + "attributionWindow": { "clickThrough": "7d" }, + "priority": 1 + }] }, "constraints": { "channels": ["ctv", "display"], @@ -399,68 +466,68 @@ POST /api/v2/buyer/campaigns/performance } \`\`\` -\`performanceConfig.objective\` is required. +\`performanceConfig\` must include \`optimizationGoals\` array with at least one goal. Each goal has a \`kind\` discriminator: \`"event"\` goals require \`eventSources\` array (each with \`eventSourceId\` and \`eventType\`); \`"metric"\` goals require \`metric\` string. Both kinds support an optional \`target\` object (\`kind: "per_ad_spend"\` for ROAS targets, \`kind: "cost_per"\` for CPA targets, each with a \`value\`), \`attributionWindow\`, and \`priority\`. -**Step 5: Launch** +**Step 5: Auto-select products (optional)** +If the campaign has discovered products (from auto-discovery at creation), let the system pick the best ones: +\`\`\`http +POST /api/v2/buyer/campaigns/{campaignId}/auto-select-products +\`\`\` +Returns selected products with budget allocations and AI-generated rationale. Present results and let the user review before launching. + +**Step 6: Launch** \`\`\`http POST /api/v2/buyer/campaigns/{campaignId}/execute \`\`\` --- -## Audience Campaign Workflow +## Account Management + +Some account management tasks are handled in the web UI at [agentic.scope3.com](https://agentic.scope3.com). Direct users to these pages for: + +| Task | URL | Capabilities | +|------|-----|--------------| +| **API Keys** | [agentic.scope3.com/user-api-keys](https://agentic.scope3.com/user-api-keys) | Create, view, edit, delete, and reveal API key secrets | +| **Team Members** | [agentic.scope3.com/admin](https://agentic.scope3.com/admin) | Invite members, manage roles, manage advertiser access | +| **Billing** | Available from user menu in the UI | Manage payment methods, view invoices (via Stripe portal) | +| **Profile** | [agentic.scope3.com/user-info](https://agentic.scope3.com/user-info) | View and update user profile | -**Status:** ❌ Not yet implemented — returns 501 Not Implemented. +**Note:** Billing and member management require admin permissions. -If a user wants audience targeting, explain this type is coming soon and suggest discovery or performance as alternatives. +### Current Account -**Future flow (once implemented):** +Get the customer the authenticated user is currently operating in: -Step 1: Discover available signals \`\`\`http -POST /api/v2/buyer/campaign/signals/discover -{ - "filters": { - "catalogTypes": ["marketplace"] - } -} +GET /api/v2/buyer/accounts/current \`\`\` -Step 2: Create audience campaign with selected signals +Returns \`{ "id", "company", "name", "role" }\` where \`role\` is the user's normalized role (\`ADMIN\`, \`MEMBER\`, or \`SUPER_ADMIN\`). + +### List Accounts + +List all customers the authenticated user has active membership on: + \`\`\`http -POST /api/v2/buyer/campaigns/audience -{ - "advertiserId": "12345", - "name": "Tech Enthusiasts Campaign", - "flightDates": { - "startDate": "2025-02-01T00:00:00Z", - "endDate": "2025-03-31T23:59:59Z" - }, - "budget": { - "total": 25000, - "currency": "USD" - }, - "signals": ["tech_enthusiasts_signal_id", "early_adopters_signal_id"] -} +GET /api/v2/buyer/accounts \`\`\` -Step 3: Launch — \`POST /campaigns/{campaignId}/execute\` - ---- +Returns \`{ "accounts": [{ "id", "company", "name", "role" }] }\` where \`role\` is the user's normalized role (\`ADMIN\`, \`MEMBER\`, or \`SUPER_ADMIN\`). -## Account Management +### Switch Account -Some account management tasks are handled in the web UI at [agentic.scope3.com](https://agentic.scope3.com). Direct users to these pages for: +Users with memberships on multiple customers can switch their active customer context: -| Task | URL | Capabilities | -|------|-----|--------------| -| **API Keys** | [agentic.scope3.com/user-api-keys](https://agentic.scope3.com/user-api-keys) | Create, view, edit, delete, and reveal API key secrets | -| **Team Members** | [agentic.scope3.com/user-management](https://agentic.scope3.com/user-management) | Invite members, manage roles, manage seat access | -| **Billing** | Available from user menu in the UI | Manage payment methods, view invoices (via Stripe portal) | -| **Profile** | [agentic.scope3.com/user-info](https://agentic.scope3.com/user-info) | View and update user profile | +\`\`\`http +POST /api/v2/buyer/accounts/switch +{ + "customerId": 123 +} +\`\`\` -**Note:** Billing and member management require admin permissions. +Returns the full user response scoped to the target customer (same shape as \`user_get_current\`), including \`user\`, \`customer\`, \`customers\`, \`organization\`, \`showPsaBox\`, and \`latestPsaVersion\`. Returns 403 if the user does not have an active membership on the target customer. --- @@ -471,29 +538,53 @@ Before creating campaigns, you MUST understand the entity hierarchy: \`\`\` Customer (your account) └── Advertiser (brand account - REQUIRED first) + ├── Catalogs (sync product/offering data to partners) ├── Campaigns (advertising campaigns) - ├── Creative Sets (ad creatives) - ├── Conversion Events (for performance tracking) + │ └── Creatives + ├── Event Sources (conversion data pipelines for optimization) └── Test Cohorts (for A/B testing) \`\`\` +### ⚠️ CRITICAL: Brand Domain Required for All Advertiser Actions + +**Before performing ANY action on an advertiser** (creating campaigns, managing accounts, syncing catalogs, etc.), check that the advertiser has a brand domain configured (the \`brand\` field is not null/missing in the advertiser response). + +If the advertiser does NOT have a brand domain: +1. **Do not proceed** with the requested action. +2. Inform the user: "This advertiser does not have a brand domain configured, which is required. Please provide a brand domain (e.g., \`nike.com\`) so I can update the advertiser." +3. Once the user provides one, update the advertiser: \`PUT /api/v2/buyer/advertisers/{id}\` with \`{ "brand": "nike.com" }\`. +4. Only then continue with the original request. + ### Setup Checklist **Before you can run a campaign, you need:** -1. **Advertiser** (REQUIRED) +1. **Advertiser with brand domain** (REQUIRED) - First, check if one exists: \`GET /api/v2/buyer/advertisers\` - - If not, create one: \`POST /api/v2/buyer/advertisers\` (requires \`brandDomain\`) + - If not, create one: \`POST /api/v2/buyer/advertisers\` (requires \`brand\`) - An advertiser represents a brand/company you're advertising for - The brand is resolved automatically from the domain during creation + - If the brand is not yet registered, the API returns an enriched preview — show it to the user, then retry with \`saveBrand: true\` to register the brand and create the advertiser + - Set \`sandbox: true\` to create a sandbox advertiser — all ADCP operations will use sandbox-flagged accounts with no real spend. See **Sandbox Mode** below. Sandbox mode cannot be changed after creation. + + **Sandbox Mode** + + Sandbox mode lets you test the full media buying lifecycle — discovery, campaign creation, creatives, and delivery — without real platform calls or spending real money. -2. **Conversion Events** (REQUIRED for performance campaigns) - - Create tracking events: \`POST /api/v2/buyer/advertisers/{advertiserId}/conversion-events\` - - Configure what actions to optimize for (purchases, signups, etc.) + - Sandbox is **account-level, not per-request**. The seller provisions a dedicated sandbox account, and every request using that \`account_id\` is automatically treated as sandbox. This eliminates the risk of accidentally mixing real and test traffic in a multi-step flow. + - All discovered accounts for a sandbox advertiser are sandbox accounts — \`list_accounts\` is called with \`sandbox: true\`. + - The correct sandbox \`account_id\` is automatically injected into \`create_media_buy\`, \`get_media_buy_delivery\`, and \`get_products\` — delivery and reporting data are fully scoped to the sandbox environment. + - Responses contain simulated but realistic data. + - Reference: https://docs.adcontextprotocol.org/docs/media-buy/advanced-topics/sandbox#sandbox-mode -3. **Creative Sets** (OPTIONAL) - - Create creative container: \`POST /api/v2/buyer/advertisers/{advertiserId}/creative-sets\` - - Add assets to it for ad delivery +2. **Event Sources** (REQUIRED for performance optimization) + - Register conversion data pipelines: \`POST /api/v2/buyer/advertisers/{advertiserId}/event-sources\` + - Referenced by \`eventSourceId\` in optimization goals + +3. **Creative Manifests** (REQUIRED) + - Every campaign needs creative assets + - **Asset creation and upload is UI-only** — look up the dashboard URL via \`GET /api/v2/buyer/dashboard-url\`, resolve the advertiser + campaign IDs, then direct the buyer to: \`{dashboard_url}/campaign-creative-assets/{advertiserId}/{campaignId}\` + - Via MCP you can only **list**, **get**, **update metadata**, and **delete** existing manifests — see the **Creative Manifests** section below --- @@ -502,11 +593,13 @@ Customer (your account) | Concept | Description | Required For | |---------|-------------|--------------| | **Advertiser** | Top-level account representing a brand/company | Everything | +| **Catalog** | Product/offering data synced to partner platforms via ADCP | Catalog sync | | **Campaign** | Advertising campaign with budget, dates, targeting | Running ads | -| **Creative Set** | Collection of creative assets | Ad delivery | -| **Conversion Event** | Trackable action (purchase, signup, etc.) | Performance campaigns | +| **Creative Manifest** | Campaign-scoped container for creative assets (images, videos, URLs). Created/uploaded via UI only; list/get/update/delete via MCP | Ad delivery | +| **Event Source** | Conversion data pipeline (pixel, SDK, etc.) | Performance optimization | +| **Syndication** | Push audiences/events/catalogs to ADCP agents | Audience distribution | | **Test Cohort** | A/B test configuration | Experimentation | -| **Media Buy** | Executed purchase record (within reporting hierarchy) | Reporting | +| **Media Buy** | Executed purchase record — managed via campaign endpoints (\`PUT /campaigns/{id}\`), no standalone endpoints | Campaigns, Reporting | --- @@ -517,14 +610,22 @@ If you're starting fresh with a new advertiser, follow these steps. \`\`\` Step 1: Check if an advertiser already exists GET /api/v2/buyer/advertisers -→ If advertisers exist, you can use one. If not, create one with a brandDomain (see Create Advertiser below). +→ If advertisers exist, you can use one. If not, create one (see Step 1b). -Step 2: Create conversion events (for performance campaigns) -POST /api/v2/buyer/advertisers/{advertiserId}/conversion-events +Step 1b: Create an advertiser (if needed) +POST /api/v2/buyer/advertisers +{ "name": "Acme Corp", "brand": "acme.com" } +→ If brand is registered: advertiser is created with linked brand. +→ If brand is not registered: returns enriched brand preview. Show it to the user, + then retry with saveBrand: true to register the brand and create the advertiser: + POST /api/v2/buyer/advertisers + { "name": "Acme Corp", "brand": "acme.com", "saveBrand": true } + +Step 2: Create an event source (for performance optimization) +POST /api/v2/buyer/advertisers/{advertiserId}/event-sources { - "name": "Purchase", - "type": "PURCHASE", - "description": "Completed purchase event" + "eventSourceId": "website_pixel", + "name": "Website Pixel" } Step 3: Now you can discover products and create campaigns! @@ -538,15 +639,38 @@ Step 3: Now you can discover products and create campaigns! #### List Advertisers \`\`\`http -GET /api/v2/buyer/advertisers?status=ACTIVE&name=Acme&includeBrand=true&take=50&skip=0 +GET /api/v2/buyer/advertisers?status=ACTIVE&name=Acme&includeBrand=true&includeAccounts=true&limit=10&offset=0 \`\`\` **Query Parameters (Filters):** - \`status\` (optional): Filter by status - \`ACTIVE\` or \`ARCHIVED\` - \`name\` (optional): Filter by name (case-insensitive, partial match). Example: \`name=Acme\` matches "Acme Corp", "acme inc", etc. - \`includeBrand\` (optional, boolean): Include resolved brand information (full ADCP manifest, logos, colors, industry, tagline, tone) for each advertiser. Default: \`false\`. Pass \`true\` or \`1\` to include. -- \`take\` (optional): Results per page (default: 50, max: 250) -- \`skip\` (optional): Pagination offset (default: 0) +- \`includeAccounts\` (optional, boolean): Embed linked partner accounts for each advertiser in the response. Default: \`true\`. **Use this instead of calling \`GET /advertisers/{id}/accounts\` per advertiser — avoids N+1 calls.** Each advertiser will have a \`linkedAccounts\` array containing \`{ partnerId, accountId, billingType }\` entries. +- \`limit\` (optional): Maximum number of advertisers per page (default: 10, max: 10) +- \`offset\` (optional): Pagination offset (default: 0) + +**Response format:** +\`\`\`json +{ + "items": [ ... ], + "total": 42, + "hasMore": true, + "nextOffset": 10 +} +\`\`\` +Use \`nextOffset\` as the \`offset\` parameter for the next page. When \`hasMore\` is \`false\`, \`nextOffset\` is \`null\`. + +**Display Requirements — ALWAYS include when listing advertisers:** + +Present each advertiser as a structured entry (not prose). For every advertiser, show: +- **Name** and **ID** +- **Status** (ACTIVE, ARCHIVED) +- **Brand** — linked brand name or domain (show "No brand" if missing) +- **Sandbox** — Yes/No +- **Linked Accounts** — list each by partner name, account ID, and status. If none, say "No linked accounts" and offer to discover/link. For sandbox advertisers, do not mention linking accounts — sandbox accounts are provisioned automatically when the user has credentials for a sales agent. + +Never summarize into a sentence like "You have 13 advertisers." Always show the per-item details above for every advertiser in the response. **Note:** \`GET /api/v2/buyer/advertisers/{id}\` (single advertiser) always returns full brand details — no need for \`includeBrand\`. @@ -566,7 +690,7 @@ Returns the advertiser with full brand details (equivalent to \`includeBrand=tru "status": "ACTIVE", "createdAt": "2026-02-15T10:00:00Z", "updatedAt": "2026-02-15T10:00:00Z", - "brandDomain": "acme.com", + "brand": "acme.com", "brandWarning": null, "linkedBrand": { "id": "brand_123", @@ -602,33 +726,59 @@ Returns the advertiser with full brand details (equivalent to \`includeBrand=tru #### Create Advertiser -**⚠️ IMPORTANT: \`brandDomain\` is required when creating an advertiser.** +**⚠️ IMPORTANT: \`brand\` is required when creating an advertiser.** When a user asks to create an advertiser: 1. **Ask for the name** - "What would you like to name your advertiser?" -2. **Ask for the brand domain** - "What is the brand's website domain? (e.g., nike.com)" -3. **Create the advertiser** with name and brandDomain +2. **Ask for the brand** - "What is the brand's website domain? (e.g., nike.com)" +3. **Create the advertiser** with name and brand + +The system resolves the brand via Addie (AdCP registry + Brandfetch enrichment). There are three possible outcomes: + +**Outcome 1: Brand exists in the registry** — Advertiser is created successfully with the linked brand. -The system resolves the brand via Addie (AdCP registry + Brandfetch enrichment). If the brand is found only via enrichment (not yet registered), the create **fails** with a \`VALIDATION_ERROR\`. The error response includes \`error.details.enrichedBrand\` containing the brand data that was found (name, domain, manifest with logos, colors, industry, tagline, tone, etc.). +**Outcome 2: Brand found via enrichment only (not yet registered)** — The create **fails** with a \`VALIDATION_ERROR\`. The error response includes \`error.details.enrichedBrand\` containing the brand data that was found (name, domain, manifest with logos, colors, industry, tagline, tone, etc.). **When this error occurs, you MUST do BOTH of the following:** -1. **ALWAYS show the enriched brand data first.** Present \`error.details.enrichedBrand\` to the user — show the brand name, domain, industry, colors, logo URL, tagline, tone, and any other fields present. This lets the user confirm the right brand was found. -2. **Then explain next steps.** Tell them this brand needs to be registered before an advertiser can be created. Direct them to register at https://adcontextprotocol.org/chat.html or https://agenticadvertising.org/brand, then retry. +1. **ALWAYS show the enriched brand preview first.** Present \`error.details.enrichedBrand\` to the user — show the brand name, domain, industry, colors, logo URL, tagline, tone, and any other fields present. This lets the user review and confirm the right brand was found. +2. **Then offer to save and create.** Tell the user they can save this brand to the AdCP registry and create the advertiser in one step by retrying the same request with \`saveBrand: true\`. The user may also choose to manually adjust the brand data before saving. -If no brand data is found at all (no enrichment results), the create also fails — tell the user to register their brand first. +**Retry with \`saveBrand: true\`:** +\`\`\`http +POST /api/v2/buyer/advertisers +{ + "name": "Acme Corp", + "brand": "acme.com", + "description": "Global advertising account", + "saveBrand": true +} +\`\`\` +This saves the enriched brand to the AdCP registry and creates the advertiser with the linked brand in one call. +**Outcome 3: No brand data found at all** — The create fails. Tell the user to register their brand at https://adcontextprotocol.org/chat.html or https://agenticadvertising.org/brand, then retry. + +**Initial request (without \`saveBrand\`):** \`\`\`http POST /api/v2/buyer/advertisers { "name": "Acme Corp", - "brandDomain": "acme.com", + "brand": "acme.com", "description": "Global advertising account" } \`\`\` -**Response** includes \`brandDomain\`, \`linkedBrand\`, and optional \`brandWarning\` (e.g., if data came from Brandfetch enrichment rather than a well-known manifest). +**Request fields:** +- \`name\` (required): Advertiser name +- \`brand\` (required): Brand domain (e.g., \`"nike.com"\`) +- \`description\` (optional): Description +- \`saveBrand\` (optional, boolean, default \`false\`): When \`true\`, saves an enriched brand to the AdCP registry if the brand is not yet registered. Set this after reviewing the enriched brand preview returned from a previous attempt. +- \`linkedAccounts\` (optional, array): Accounts to link at creation time. Each item: \`{ partnerId, accountId, billingType? }\`. Use \`GET /advertisers/{advertiserId}/accounts/available?partnerId={agentId}\` to discover valid accountIds — never ask the user to provide one manually. +- \`optimizationApplyMode\` (optional, string): \`"AUTO"\` or \`"MANUAL"\` (default \`"MANUAL"\`). Controls whether Scope3 AI model optimizations to media buys are applied automatically or require manual approval for campaigns under this advertiser. +- \`utmConfig\` (optional, array, max 20): Default UTM parameters for this advertiser. These are appended to landing page URLs during clickthrough redirection. Each item: \`{ paramKey, paramValue }\`. \`paramKey\` is the query parameter name (e.g. \`"utm_source"\`, \`"bg_campaign"\`). \`paramValue\` is a macro (e.g. \`"{CAMPAIGN_ID}"\`) resolved dynamically at click time, or a static string (e.g. \`"scope3"\`). Available macros follow the ADCP universal macros spec: https://docs.adcontextprotocol.org/docs/creative/universal-macros#universal-macros. If omitted, defaults are applied: \`utm_source=scope3\`, \`utm_medium=agentic\`, \`utm_campaign={CAMPAIGN_ID}\`, \`utm_content={CREATIVE_ID}\`, \`utm_media_buy={MEDIA_BUY_ID}\`, \`utm_package={PACKAGE_ID}\`. Campaign-level UTM config can override these per param key. + +**Response** includes \`brand\`, \`linkedBrand\`, \`optimizationApplyMode\`, optional \`utmConfig\` (seat-level UTM params, only present when configured), and optional \`brandWarning\` (e.g., if data came from Brandfetch enrichment rather than a well-known manifest). #### Update Advertiser \`\`\`http @@ -636,38 +786,90 @@ PUT /api/v2/buyer/advertisers/{id} { "name": "Acme Corporation", "description": "Updated description", - "brandDomain": "newbrand.com" + "brand": "newbrand.com" } \`\`\` -**Optional fields:** \`name\`, \`description\`, \`brandDomain\` +**Optional fields:** \`name\`, \`description\`, \`brand\`, \`linkedAccounts\` (array of \`{ partnerId, accountId, billingType? }\` to add — does not remove existing links), \`optimizationApplyMode\` (\`"AUTO"\` or \`"MANUAL"\` — controls whether Scope3 AI model optimizations to media buys are applied automatically or require manual approval for campaigns under this advertiser), \`utmConfig\` (array of \`{ paramKey, paramValue }\`, max 20 — replaces all existing seat-level UTM params; pass \`[]\` to clear). Discover valid accountIds via \`GET /advertisers/{advertiserId}/accounts/available?partnerId={agentId}\` — never ask the user to provide an account ID. -If \`brandDomain\` is provided, the system resolves the new brand domain and updates the linked brand agent. +If \`brand\` is provided, the system resolves the new brand and updates the linked brand agent. ---- +#### Link Agent Account to Advertiser -### Campaigns +Use this 3-step workflow to discover and link an agent's account to a specific advertiser. + +**Two-step process overview:** +1. **Register agent credentials** (customer-level) — done once per agent via \`POST /sales-agents/{agentId}/accountCredentials\` +2. **Link account to advertiser** (advertiser-level) — discover available accounts for a specific agent and link one to an advertiser + +**Prerequisites:** Agent credentials must already be registered for the relevant agent via \`POST /sales-agents/{agentId}/accountCredentials\` (see Register Agent Credentials in the Sales Agents section). **Only agents with \`requiresOperatorAuth: true\` support account linking.** To find eligible agents, use \`GET /api/v2/buyer/sales-agents?supportsRegistration=true\`. + +**⚠️ CRITICAL: Multiple Credentials for the Same Agent** + +A customer may register **multiple sets of credentials** for the same sales agent (e.g., two different Snap ad accounts with different API keys). This is fully supported. When this happens: +- Each set of credentials discovers its own set of ad accounts via \`list_accounts\` +- The discovery endpoint (\`accounts/available\`) **requires a \`credentialId\` parameter** so the system knows which credential to use for discovery +- If the customer has multiple credentials and \`credentialId\` is omitted, the API returns a **validation error listing the available credential IDs** — present these to the user and ask them to pick +- Once the user picks a credential, re-call the discovery endpoint with \`credentialId\` to get that credential's accounts +- When the user links an account, all future operations for that advertiser+agent pair automatically use the credential associated with that account + +**Workflow when multiple credentials exist:** +1. Call \`GET /sales-agents/accountCredentials\` to list the customer's registered credentials +2. Present the credentials to the user (show \`id\` and \`accountIdentifier\` for each) +3. Ask the user which credential to use +4. Pass the chosen \`credentialId\` to the discovery endpoint + +**Step 1 — Create advertiser with brand** +\`\`\`http +POST /api/v2/buyer/advertisers +{ "name": "Acme Corp", "brand": "acme.com" } +\`\`\` + +**Step 2 — Discover available accounts for the agent** -The API supports three campaign types with **type-specific endpoints**: +**⚠️ CRITICAL: Account IDs MUST come from the discovery endpoint — NEVER from user input.** +- You MUST call the discovery endpoint below and use ONLY the \`accountId\` values returned in the response. +- If the user provides an account ID or account name verbally (e.g., "the account ID is 06cd7033..."), do NOT use that value. Instead, call the discovery endpoint and match against the returned results. +- If no accounts are returned from discovery, tell the user no matching accounts were found. Do NOT pretend to link an account that was not returned by the API. +- **NEVER fabricate, guess, or use a user-provided account ID directly.** The only valid account IDs are those returned by this endpoint. + +\`\`\`http +GET /api/v2/buyer/advertisers/{advertiserId}/accounts/available?partnerId={agentId} +GET /api/v2/buyer/advertisers/{advertiserId}/accounts/available?partnerId={agentId}&credentialId={credentialId} +\`\`\` +- \`credentialId\` is **required when the customer has multiple credentials** registered for this agent. If omitted with multiple credentials, the API returns a validation error listing available credential IDs — present them to the user and ask which to use, then retry with \`credentialId\`. +- \`credentialId\` is optional when the customer has only one credential for the agent. +- Returns accounts filtered by the advertiser's brand domain for the specified agent and credential. +- Response includes \`accounts\` array with \`accountId\`, \`name\`, \`house\`, \`advertiser\`, \`partnerId\`, and \`billingOptions.supported\`. +- **Show ALL discovered accounts to the user and let them pick.** Present each account's \`name\` (and \`advertiser\` if different from the brand). +- If \`accounts\` is empty, tell the user no matching accounts were found for that agent. Do NOT proceed with linking. +- **Do NOT ask about billing type.** Use \`billingOptions.default\` from the response if available. Only include \`billingType\` in the link request if the response shows multiple \`billingOptions.supported\` values AND no default — in that case, present the options and ask the user to choose. + +**Step 3 — Link the selected account to the advertiser** +\`\`\`http +PUT /api/v2/buyer/advertisers/{advertiserId} +{ + "linkedAccounts": [ + { "partnerId": "snap_6e2d13705a26", "accountId": "acc_123" } + ] +} +\`\`\` +- The \`accountId\` here MUST be one returned from Step 2. Never use a value from any other source. +- \`linkedAccounts\` adds accounts — it does not remove existing links. Include \`billingType\` if the agent requires a specific billing arrangement. -| Type | Create Endpoint | Update Endpoint | Prerequisites | -|------|-----------------|-----------------|---------------| -| \`discovery\` | \`POST /campaigns/discovery\` | \`PUT /campaigns/discovery/{id}\` | Advertiser + **bundleId (required)** | -| \`performance\` | \`POST /campaigns/performance\` | \`PUT /campaigns/performance/{id}\` | Advertiser + performanceConfig.objective | -| \`audience\` | \`POST /campaigns/audience\` | \`PUT /campaigns/audience/{id}\` | **Not implemented (501)** | +--- -**IMPORTANT**: Each campaign type has its own create and update endpoints. You cannot create a campaign at the generic \`/campaigns\` endpoint. +### Campaigns -For full step-by-step workflows, see the Discovery Campaign Workflow and Performance Campaign Workflow sections above. +Campaigns use a single endpoint for creation and update. Configuration is done through action endpoints. #### List Campaigns \`\`\`http -GET /api/v2/buyer/campaigns?advertiserId=12345&type=discovery&status=ACTIVE +GET /api/v2/buyer/campaigns?advertiserId=12345&status=ACTIVE \`\`\` **Query Parameters:** - \`advertiserId\` (optional): Filter by advertiser -- \`type\` (optional): \`discovery\`, \`audience\`, or \`performance\` - \`status\` (optional): \`DRAFT\`, \`ACTIVE\`, \`PAUSED\`, \`COMPLETED\`, \`ARCHIVED\` #### Get Campaign @@ -675,14 +877,23 @@ GET /api/v2/buyer/campaigns?advertiserId=12345&type=discovery&status=ACTIVE GET /api/v2/buyer/campaigns/{campaignId} \`\`\` -#### Create Discovery Campaign +Campaign responses include an \`audiences\` array of currently active audiences with: \`audienceId\`, \`name\`, \`status\`, \`type\` (\`"TARGET"\` or \`"SUPPRESS"\`), \`enabledAt\`. + +**DRAFT campaigns only:** The response includes \`discoveryId\`, \`products\`, and \`productCount\` fields representing the product selection from the discovery workflow. These fields are **only present while the campaign is in DRAFT status**. After execution, product data is represented through \`mediaBuys\` — do not look for \`discoveryId\` or \`products\` on executed campaigns. + +**Understanding duplicate media buys in the response:** The \`mediaBuys\` array may contain **two entries with the same media buy ID but different statuses** — e.g., one \`ACTIVE\` and one \`PENDING_APPROVAL\`. This is **normal and expected**. The \`PENDING_APPROVAL\` entry is a pending version of the media buy that has been submitted to the sales agent/publisher for approval. The original \`ACTIVE\` version remains unchanged until the publisher approves the update — once approved, the pending version becomes ACTIVE and replaces the old one. Do NOT flag this as unusual or ask the user about it — simply explain that the update is pending publisher approval. + +#### Create Campaign + +**⚠️ BEFORE creating a campaign: verify the advertiser has a brand domain.** +Call \`GET /api/v2/buyer/advertisers/{advertiserId}\` and check the \`brand\` field is not null/missing. +If it is missing, do NOT proceed — tell the user: "This advertiser doesn't have a brand domain configured. Please provide one (e.g. \`nike.com\`) so I can update it first." Then \`PUT /api/v2/buyer/advertisers/{id}\` with \`{ "brand": "..." }\` before continuing. + \`\`\`http -POST /api/v2/buyer/campaigns/discovery +POST /api/v2/buyer/campaigns { "advertiserId": "12345", - "name": "Q1 CTV Bundle", - "bundleId": "abc123-def456-ghi789", - "productIds": ["prod_123", "prod_456"], + "name": "Q1 Campaign", "flightDates": { "startDate": "2025-02-01T00:00:00Z", "endDate": "2025-03-31T23:59:59Z" @@ -691,186 +902,274 @@ POST /api/v2/buyer/campaigns/discovery "total": 50000, "currency": "USD" }, - "brief": "<<< ALWAYS include the ENTIRE brief from the client — never summarize or truncate >>>" + "brief": "<<< ALWAYS include the ENTIRE brief from the client — never summarize or truncate >>>", + "constraints": { + "channels": ["ctv", "display"], + "countries": ["US"] + }, + "discoveryId": "optional-existing-discovery-id", + "productIds": ["prod_123", "prod_456"], + "audienceConfig": { + "targetAudienceIds": ["aud_001", "aud_002"], + "suppressAudienceIds": ["aud_003"] + }, + "performanceConfig": { + "optimizationGoals": [{ + "kind": "event", + "eventSources": [ + { "eventSourceId": "es_abc123", "eventType": "purchase", "valueField": "value" } + ], + "target": { "kind": "per_ad_spend", "value": 4.0 }, + "priority": 1 + }] + } } \`\`\` **Required fields:** - \`advertiserId\`: Advertiser ID - \`name\`: Campaign name (1-255 chars) -- \`bundleId\`: Bundle ID from \`POST /bundles\` **(required)** - \`flightDates\`: Start and end dates - \`budget\`: Total and currency **Optional fields:** -- \`productIds\`: Product IDs to pre-select from the bundle +- \`brief\`: Campaign brief. **MUST be the ENTIRE brief from the client — never summarize or truncate.** - \`constraints.channels\`: Target channels (display, olv, ctv, social) - \`constraints.countries\`: Target countries (ISO 3166-1 alpha-2 codes) -- \`brief\`: Campaign brief. **MUST be the ENTIRE brief from the client — never summarize or truncate.** - -#### Update Discovery Campaign +- \`discoveryId\`: Attach an existing discovery session +- \`productIds\`: Product IDs to pre-select from the discovery session (requires discoveryId) +- \`audienceConfig\`: Audience targeting and suppression. \`targetAudienceIds\` (string array) — audiences to include. \`suppressAudienceIds\` (string array) — audiences to exclude. Audience IDs come from \`GET /advertisers/{accountId}/audiences\`. +- \`performanceConfig\`: Contains \`optimizationGoals\` array. Each goal has \`kind\` (\`"event"\` or \`"metric"\`). Event goals have \`eventSources\` array (each with \`eventSourceId\`, \`eventType\`, optional \`valueField\`), optional \`target\` (\`kind: "per_ad_spend"\` or \`kind: "cost_per"\` with \`value\`), optional \`attributionWindow\`, optional \`priority\`. Metric goals have \`metric\` string, optional \`target\`, optional \`priority\`. +- \`optimizationApplyMode\`: \`"AUTO"\` or \`"MANUAL"\` (default). Controls whether Scope3 AI model optimizations to media buys are applied automatically or require manual approval. Overrides the advertiser-level default. +- \`utmConfig\`: Campaign-level UTM parameter overrides. Object with \`params\` (array of \`{ paramKey, paramValue }\`, max 20) and optional \`deleteMissing\` (boolean — if \`true\`, removes campaign-level UTM params not in this request; if \`false\`/omitted, additive mode). Campaign UTM params override seat-level defaults per matching \`paramKey\`. + +**After creating a campaign, suggest ONLY these next steps (never mention strategies, tactics, or media plans):** +1. **Discover products** — find and attach inventory via \`POST /discovery/discover-products\` +2. **Attach audiences** — link synced audiences for targeting/suppression via \`PUT /campaigns/{id}\` with \`audienceConfig\` +3. **Set performance configuration** — configure optimization goals via \`PUT /campaigns/{id}\` with \`performanceConfig\` + +#### Update Campaign \`\`\`http -PUT /api/v2/buyer/campaigns/discovery/{campaignId} +PUT /api/v2/buyer/campaigns/{campaignId} { "name": "Updated Campaign Name", - "budget": { - "total": 75000 + "budget": { "total": 75000 }, + "audienceConfig": { + "targetAudienceIds": ["aud_004"], + "suppressAudienceIds": ["aud_005"] }, - "productIds": ["prod_789"] + "performanceConfig": { + "optimizationGoals": [{ + "kind": "event", + "eventSources": [ + { "eventSourceId": "es_abc123", "eventType": "purchase", "valueField": "value" } + ], + "target": { "kind": "per_ad_spend", "value": 5.0 }, + "priority": 1 + }] + } } \`\`\` -All fields are optional. Campaign must be of type "discovery". +All fields are optional. \`audienceConfig\` is **additive** by default — it adds audiences without removing existing ones. Set \`deleteMissing: true\` inside \`audienceConfig\` to replace the full audience set (audiences not in the list are soft-disabled). To remove all audiences, send \`{ "audienceConfig": { "deleteMissing": true } }\`. ---- +##### Updating Media Buys via Campaign Update -#### Create Performance Campaign +**There are no standalone media buy endpoints.** To update media buys (budget, pacing, creatives, etc.), include the \`mediaBuys\` array in the campaign update body. Each entry targets a specific media buy by ID. + +**IMPORTANT: ACTIVE media buys use \`packages\`, DRAFT media buys use \`products\`.** +- When a media buy is **ACTIVE** (already executed/deployed), update its **\`packages\`** — these are the deployed line items on the publisher side. +- When a media buy is **DRAFT** (not yet executed), update its **\`products\`** — these are the pre-execution product selections. + +**Example — Update budget and pacing for an ACTIVE media buy's package:** \`\`\`http -POST /api/v2/buyer/campaigns/performance +PUT /api/v2/buyer/campaigns/{campaignId} { - "advertiserId": "12345", - "name": "Q1 ROAS Campaign", - "flightDates": { - "startDate": "2025-02-01T00:00:00Z", - "endDate": "2025-03-31T23:59:59Z" - }, - "budget": { - "total": 100000, - "currency": "USD" - }, - "performanceConfig": { - "objective": "ROAS", - "goals": { - "targetRoas": 4.0 + "mediaBuys": [ + { + "mediaBuyId": "mb_abc123", + "packages": [ + { + "packageId": "pkg_xyz", + "budget": 5000, + "pacing": "even" + } + ], + "updated_reason": "Increase budget for Q2 push" } - }, - "constraints": { - "channels": ["ctv", "display"], - "countries": ["US"] - } + ] } \`\`\` -**Required fields:** -- \`advertiserId\`: Advertiser ID -- \`name\`: Campaign name (1-255 chars) -- \`flightDates\`: Start and end dates -- \`budget\`: Total and currency -- \`performanceConfig.objective\`: One of \`ROAS\`, \`CONVERSIONS\`, \`LEADS\`, \`SALES\` - -#### Update Performance Campaign +**Example — Update a DRAFT media buy's products:** \`\`\`http -PUT /api/v2/buyer/campaigns/performance/{campaignId} +PUT /api/v2/buyer/campaigns/{campaignId} { - "name": "Updated Campaign Name", - "performanceConfig": { - "goals": { - "targetRoas": 5.0 + "mediaBuys": [ + { + "mediaBuyId": "mb_draft456", + "products": [ + { + "product_id": "prod_001", + "budget": 3000, + "pacing": "even" + } + ] } - } + ] } \`\`\` -All fields are optional. Campaign must be of type "performance". - ---- -#### Create Audience Campaign (Not Implemented) +**Media buy update fields:** +- \`mediaBuyId\` (required): ID of the media buy to update (from campaign GET response \`mediaBuys\` array) +- \`name\` (optional): Updated media buy name +- \`packages\` (optional, for **ACTIVE** media buys): Array of package updates. Each: \`packageId\` (required), \`budget\`, \`pacing\` (\`"even"\` or \`"asap"\`), \`bidPrice\`, \`creative_ids\` +- \`products\` (optional, for **DRAFT** media buys): Array of product updates. Each: \`product_id\` (required), \`pricingOptionId\`, \`budget\`, \`pacing\` (\`"asap"\`, \`"even"\`, \`"front_loaded"\`), \`bidPrice\` +- \`start_time\` (optional): \`"asap"\` or ISO 8601 date-time +- \`end_time\` (optional): ISO 8601 date-time +- \`creative_ids\` (optional): Updated creative assignments +- \`updated_reason\` (optional): Reason for update (stored with version history) + +**Media buy update versioning:** When you update an ACTIVE media buy, the system creates a **new pending version** with status \`PENDING_APPROVAL\`. This is expected: +- The original ACTIVE version remains unchanged until the sales agent (publisher) approves the update. +- The campaign GET response will temporarily show **two entries** for the same media buy: the original ACTIVE version and the new PENDING_APPROVAL version. +- The pending version's \`packages\`/\`products\` may not immediately reflect the requested changes — the updated values are submitted to the sales agent for approval. +- Once the sales agent approves, the pending version becomes ACTIVE and replaces the old one. +- **Do NOT treat this as an error or try alternative approaches.** Simply inform the user that the update has been submitted and is pending approval from the sales agent/publisher. + +#### Delete Campaign \`\`\`http -POST /api/v2/buyer/campaigns/audience +DELETE /api/v2/buyer/campaigns/{campaignId} \`\`\` -**Returns 501 Not Implemented** - Audience campaigns are not yet available. -#### Update Audience Campaign (Not Implemented) -\`\`\`http -PUT /api/v2/buyer/campaigns/audience/{campaignId} -\`\`\` -**Returns 501 Not Implemented** - Audience campaigns are not yet available. +--- + +#### Campaign Action Endpoints #### Execute Campaign (Launch) \`\`\`http POST /api/v2/buyer/campaigns/{campaignId}/execute \`\`\` -Transitions campaign from DRAFT to ACTIVE. + +**Optional request body:** +\`\`\`json +{ + "debug": true +} +\`\`\` + +**Response:** +\`\`\`json +{ + "campaignId": "campaign_abc123", + "previousStatus": "DRAFT", + "newStatus": "ACTIVE", + "success": true +} +\`\`\` + +**On partial failure** (some media buys failed to execute): +\`\`\`json +{ + "campaignId": "campaign_abc123", + "previousStatus": "DRAFT", + "newStatus": "ACTIVE", + "success": false, + "errors": [ + { + "mediaBuyId": "mb_xyz", + "salesAgentId": "snap_abc", + "message": "Failed to submit media buy to publisher: ...", + "debug": { + "request": { "...full ADCP create_media_buy request..." }, + "response": { "...full ADCP response from sales agent..." }, + "debugLogs": [ { "...A2A request/response logs..." } ], + "error": "error message" + } + } + ] +} +\`\`\` + +- \`success\` is \`false\` when any media buy execution failed +- \`errors\` array contains structured error objects per failed media buy +- \`debug\` field contains the same debug info as v1 \`execute_media_buy\` (full ADCP request, response, and A2A debug logs) — only present when \`debug: true\` was sent in the request body +- Campaign is still set to ACTIVE even with partial failures — re-execute to retry failed media buys + +**Note on Media Buys:** Media buys are child resources of campaigns — there are **no standalone media buy endpoints**. Media buys are auto-created when a campaign is executed (\`POST /campaigns/{id}/execute\`), included in campaign GET responses (\`mediaBuys\` array), and modified through campaign updates (\`PUT /campaigns/{id}\` with \`mediaBuys\` array). For ACTIVE media buys, update \`packages\` (deployed line items); for DRAFT media buys, update \`products\`. See "Updating Media Buys via Campaign Update" above for full schema and examples. #### Pause Campaign \`\`\`http POST /api/v2/buyer/campaigns/{campaignId}/pause \`\`\` ---- - -### Bundles - -#### Create Bundle +#### Auto-Select Products \`\`\`http -POST /api/v2/buyer/bundles -{ - "advertiserId": "12345", - "channels": ["ctv", "display"], - "countries": ["US", "CA"], - "brief": "<<< ALWAYS include the ENTIRE brief from the client here — never summarize >>>", - "budget": 50000, - "flightDates": { - "startDate": "2025-02-01T00:00:00Z", - "endDate": "2025-03-31T23:59:59Z" - } -} +POST /api/v2/buyer/campaigns/{campaignId}/auto-select-products \`\`\` +No request body. Automatically selects products from the campaign's discovery session and allocates budget based on measurability. Replaces any previous selections. Requires a performance campaign (\`performanceConfig\` set) with discovered products. + +**Response:** +- \`selectedProducts\` (array): Products with budget allocations (\`productId\`, \`salesAgentId\`, \`budget\`, \`cpm\`, \`pricingOptionId\`) +- \`budgetContext\` (object): \`campaignBudget\`, \`totalAllocated\`, \`remainingBudget\`, \`currency\` +- \`selectionRationale\` (string): Explanation of the selection strategy +- \`selectionMethod\` (string): \`"scoring"\`, \`"measurability"\`, or \`"cpm_heuristic"\` +- \`testBudgetPerProduct\` (number, optional): Test budget allocated per product +- \`productCount\` (number): Total products selected -**Request Parameters:** -- \`advertiserId\` (required): Advertiser ID to resolve brand manifest -- \`channels\` (optional): Channels to search (defaults to ["display", "olv", "ctv", "social"]). Accepted values: display, olv, ctv, social, video (alias for olv). -- \`countries\` (optional): Target countries (defaults to brand agent countries) -- \`brief\` (optional): Natural language context for product search. **MUST be the ENTIRE brief from the client — never summarize or truncate.** -- \`flightDates\` (optional): Flight dates for availability filtering -- \`budget\` (optional): Budget for budget context -- \`salesAgentIds\` (optional, array): Filter products by exact sales agent ID(s). These are stored and used as defaults when discovering products for this bundle. -- \`salesAgentNames\` (optional, array): Filter products by sales agent name(s) (case-insensitive substring match). These are stored and used as defaults when discovering products for this bundle. +--- -**Response:** \`{ "bundleId": "abc123-def456-ghi789" }\` +### Discovery -#### Discover Products (Auto-Creates Bundle) +#### Discover Products -Creates a bundle and discovers products in one call. +Discovers products based on advertiser context and returns a discoveryId for managing selections. \`\`\`http -POST /api/v2/buyer/bundles/discover-products +POST /api/v2/buyer/discovery/discover-products { "advertiserId": "12345", "channels": ["ctv", "display"], "countries": ["US"], "brief": "<<< ALWAYS include the ENTIRE brief from the client here — never summarize >>>", "publisherDomain": "example", - "salesAgentNames": ["Acme Ad Exchange"] + "salesAgentNames": ["Acme Ad Exchange"], + "debug": true } \`\`\` **Filtering Parameters:** - \`publisherDomain\` (optional): Filter by publisher domain (exact domain component match) +- \`pricingModel\` (optional): Filter by pricing model (\`cpm\`, \`vcpm\`, \`cpc\`, \`cpcv\`, \`cpv\`, \`cpp\`, \`flat_rate\`) - \`salesAgentIds\` (optional, array): Filter by exact sales agent ID(s) - \`salesAgentNames\` (optional, array): Filter by sales agent name(s) (case-insensitive substring match) -#### Discover Products for Existing Bundle +**Debug Parameter:** +- \`debug\` (optional, boolean): When \`true\`, includes detailed ADCP agent request/response debug logs in the response. Returns an \`agentResults\` array with per-agent success/failure status, raw response data, and full HTTP request/response logs (authorization headers redacted). Same structure as v1 \`media_product_discover\` debug output. + +#### Discover Products for Existing Session \`\`\`http -GET /api/v2/buyer/bundles/{bundleId}/discover-products?groupLimit=10&groupOffset=0&productsPerGroup=5 +GET /api/v2/buyer/discovery/{discoveryId}/discover-products?groupLimit=10&groupOffset=0&productsPerGroup=15 \`\`\` **Query Parameters (Pagination):** -- \`groupLimit\` (optional): Max product groups (default: 10, max: 50) +- \`groupLimit\` (optional): Max product groups (default: 10, max: 10) - \`groupOffset\` (optional): Groups to skip (default: 0) -- \`productsPerGroup\` (optional): Max products per group (default: 5, max: 50) +- \`productsPerGroup\` (optional): Max products per group (default: 10, max: 15) - \`productOffset\` (optional): Products to skip within each group (default: 0) **Query Parameters (Filtering):** - \`publisherDomain\` (optional): Filter by publisher domain (exact component match). "hulu" matches "hulu.com" but "hul" does not +- \`pricingModel\` (optional): Filter by pricing model (\`cpm\`, \`vcpm\`, \`cpc\`, \`cpcv\`, \`cpv\`, \`cpp\`, \`flat_rate\`) - \`salesAgentIds\` (optional, comma-separated): Filter by sales agent ID(s) - \`salesAgentNames\` (optional, comma-separated): Filter by sales agent name(s) (case-insensitive substring match) +- \`debug\` (optional): When \`true\`, includes ADCP agent request/response debug logs in the response (see debug section below) -Filters can be combined. Example: \`?publisherDomain=example&salesAgentNames=Acme Ad Exchange\` +Filters can be combined. Example: \`?publisherDomain=example&pricingModel=cpm&salesAgentNames=Acme Ad Exchange\` **Response:** \`\`\`json { - "bundleId": "abc123-def456-ghi789", + "discoveryId": "abc123-def456-ghi789", "productGroups": [ { "groupId": "group-0", @@ -879,10 +1178,13 @@ Filters can be combined. Example: \`?publisherDomain=example&salesAgentNames=Acm { "productId": "product_123", "name": "Premium CTV Inventory", - "publisher": "example.com", "channel": "ctv", - "cpm": 12.50, - "salesAgentId": "agent_456" + "bidPrice": 12.50, + "salesAgentId": "agent_456", + "publisherProperties": [ + { "publisherDomain": "hulu.com", "selectionType": "all" }, + { "publisherDomain": "espn.com", "selectionType": "by_id" } + ] } ], "productCount": 5, @@ -905,14 +1207,43 @@ Filters can be combined. Example: \`?publisherDomain=example&salesAgentNames=Acm } \`\`\` -**Pagination:** +**Product fields:** +- \`publisherProperties\` (array, optional): Publisher domains and targeting details for this product. Each product can have multiple publishers. Each entry contains \`publisherDomain\` (string) and \`selectionType\` (\`"all"\` or \`"by_id"\`). Use this to understand which publishers a product targets. + +**Debug response** (when \`debug: true\`): + +The response includes an \`agentResults\` array containing only failed agents with full ADCP request/response logs for troubleshooting: +\`\`\`json +{ + "agentResults": [ + { + "agentId": "agent_789", + "agentName": "Failed Agent", + "success": false, + "productCount": 0, + "error": "Connection timeout", + "rawResponseData": { "..." }, + "debugLogs": [ + { + "timestamp": "2026-03-18T10:00:00Z", + "type": "request", + "request": { "method": "POST", "url": "...", "headers": { "authorization": "[REDACTED]" }, "body": { "..." } }, + "response": { "status": 500, "body": { "..." } } + } + ] + } + ] +} +\`\`\` + +**Pagination:** - \`hasMoreGroups\`: Use \`groupOffset\` to fetch more groups - \`hasMoreProducts\`: Use \`productOffset\` to fetch more products within a group - To paginate products for a single group, combine \`productOffset\` with a filter (\`salesAgentIds\` or \`salesAgentNames\`) to isolate that group -#### Add Products to Bundle +#### Add Products to Selection \`\`\`http -POST /api/v2/buyer/bundles/{bundleId}/products +POST /api/v2/buyer/discovery/{discoveryId}/products { "products": [ { @@ -920,7 +1251,7 @@ POST /api/v2/buyer/bundles/{bundleId}/products "salesAgentId": "agent_456", "groupId": "ctx_123-group-0", "groupName": "Publisher Name", - "cpm": 12.50, + "bidPrice": 12.50, "budget": 5000 } ] @@ -928,17 +1259,17 @@ POST /api/v2/buyer/bundles/{bundleId}/products \`\`\` **Required per product:** \`productId\`, \`salesAgentId\`, \`groupId\`, \`groupName\` -**Optional per product:** \`cpm\`, \`budget\` +**Optional per product:** \`bidPrice\` (required when \`isFixed: false\`), \`budget\` **Response:** \`\`\`json { - "bundleId": "abc123-def456-ghi789", + "discoveryId": "abc123-def456-ghi789", "products": [ { "productId": "product_123", "salesAgentId": "agent_456", - "cpm": 12.50, + "bidPrice": 12.50, "budget": 5000, "selectedAt": "2025-02-01T10:00:00Z", "groupId": "ctx_123-group-0", @@ -954,16 +1285,16 @@ POST /api/v2/buyer/bundles/{bundleId}/products } \`\`\` -#### Get Bundle Products +#### Get Selected Products \`\`\`http -GET /api/v2/buyer/bundles/{bundleId}/products +GET /api/v2/buyer/discovery/{discoveryId}/products \`\`\` Response format same as Add Products. -#### Remove Products from Bundle +#### Remove Products from Selection \`\`\`http -DELETE /api/v2/buyer/bundles/{bundleId}/products +DELETE /api/v2/buyer/discovery/{discoveryId}/products { "productIds": ["product_123", "product_456"] } @@ -973,43 +1304,291 @@ Response format same as Add Products (with updated list). --- -### Conversion Events +### Event Sources + +Conversion data pipelines (website pixels, mobile SDKs, etc.) registered at the advertiser level. Referenced by \`eventSourceId\` in campaign optimization goals. + +Event sources can be managed through sync (bulk upsert following the ADCP spec) or through individual CRUD operations. + +#### Sync Event Sources + +Sync is the preferred way to manage event sources — it uses upsert semantics (creates or updates as needed). + +\`\`\`http +POST /api/v2/buyer/advertisers/26/event-sources/sync +{ + "account": { "account_id": "26" }, + "event_sources": [ + { + "event_source_id": "website_pixel", + "name": "Website Pixel", + "event_types": ["purchase", "add_to_cart"], + "allowed_domains": ["shop.example.com"] + }, + { + "event_source_id": "mobile_sdk", + "name": "Mobile App SDK", + "event_types": ["app_install", "purchase"] + } + ], + "delete_missing": false +} +\`\`\` + +**URL path:** \`/advertisers/{advertiserId}/event-sources/sync\` — the \`{advertiserId}\` is the numeric advertiser ID (e.g. \`26\`). Also include it in the request body as \`account.account_id\`. + +**\`account\` (required in body):** +- \`account_id\` (string): The advertiser ID — same value as the path \`{advertiserId}\` (e.g. \`"26"\`). + +**\`event_sources\` array (required, 1–50 items). Each object:** +- \`event_source_id\` (string, required): Buyer-assigned identifier, referenced by optimization goals +- \`name\` (string, optional): Human-readable label +- \`event_types\` (array, optional): IAB ECAPI event types this source handles. When omitted, accepts all types. Values: \`purchase\`, \`lead\`, \`add_to_cart\`, \`complete_registration\`, \`subscribe\`, \`app_install\`, \`start_trial\`, \`search\`, \`add_to_wishlist\`, \`view_content\`, \`initiate_checkout\`, \`add_payment_info\`, \`share\`, \`donate\`, \`find_location\`, \`schedule\`, \`contact\`, \`customize_product\`, \`submit_application\`, \`login\`, \`page_view\`, \`complete_tutorial\`, \`achieve_level\`, \`unlock_achievement\`, \`spend_credits\`, \`rate\`, \`download\`, \`custom\` +- \`allowed_domains\` (array, optional): Domains authorized to send events + +**Other optional fields:** +- \`delete_missing\` (boolean): Archive event sources not included in this request (default: false) -Required for performance campaigns to track and optimize conversions. +**Response (200):** +\`\`\`json +{ + "data": { + "event_sources": [ + { "event_source_id": "website_pixel", "action": "created" }, + { "event_source_id": "mobile_sdk", "action": "updated" } + ] + } +} +\`\`\` + +Actions: \`created\`, \`updated\`, \`unchanged\`, \`failed\`, \`deleted\` + +#### List Event Sources + +\`\`\`http +GET /api/v2/buyer/advertisers/{advertiserId}/event-sources +\`\`\` + +**Query Parameters:** +- \`take\` / \`skip\` (optional): Pagination + +#### Create Event Source +\`\`\`http +POST /api/v2/buyer/advertisers/{advertiserId}/event-sources +{ + "eventSourceId": "website_pixel", + "name": "Website Pixel", + "eventTypes": ["purchase", "add_to_cart"], + "allowedDomains": ["shop.example.com"] +} +\`\`\` + +**Required fields:** +- \`eventSourceId\` (string): Identifier referenced by optimization goals (e.g., \`"retailer_sales"\`, \`"website_pixel"\`) +- \`name\` (string): Human-readable name + +**Optional fields:** +- \`eventTypes\` (array): IAB ECAPI event types this source handles. When omitted, accepts all types. Values: \`purchase\`, \`lead\`, \`add_to_cart\`, \`complete_registration\`, \`subscribe\`, \`app_install\`, \`start_trial\`, \`search\`, \`add_to_wishlist\`, \`view_content\`, \`initiate_checkout\`, \`add_payment_info\`, \`share\`, \`donate\`, \`find_location\`, \`schedule\`, \`contact\`, \`customize_product\`, \`submit_application\`, \`login\`, \`page_view\`, \`complete_tutorial\`, \`achieve_level\`, \`unlock_achievement\`, \`spend_credits\`, \`rate\`, \`download\`, \`custom\` +- \`allowedDomains\` (array): Domains authorized to send events for this source + +#### Get Event Source +\`\`\`http +GET /api/v2/buyer/advertisers/{advertiserId}/event-sources/{eventSourceId} +\`\`\` + +#### Update Event Source +\`\`\`http +PUT /api/v2/buyer/advertisers/{advertiserId}/event-sources/{eventSourceId} +{ + "name": "Updated Pixel Name", + "eventTypes": ["purchase"] +} +\`\`\` -#### List Conversion Events +All fields are optional. + +#### Delete Event Source \`\`\`http -GET /api/v2/buyer/advertisers/{advertiserId}/conversion-events +DELETE /api/v2/buyer/advertisers/{advertiserId}/event-sources/{eventSourceId} \`\`\` -#### Create Conversion Event +--- + +### Event Summary + +Get hourly-aggregated event counts for an advertiser. Use this to verify that events (impressions, clicks, conversions, etc.) are being ingested before setting up optimization goals. + +**Important:** Event data is aggregated hourly. Newly reported events may take up to 1 hour to appear in this summary. If the user has just started reporting events, let them know to wait before checking. + +#### Get Event Summary \`\`\`http -POST /api/v2/buyer/advertisers/{advertiserId}/conversion-events +GET /api/v2/buyer/advertisers/{advertiserId}/events/summary +\`\`\` + +**Query Parameters:** +- \`eventType\` (string, optional): Filter by event type — one of \`impression\`, \`click\`, \`conversion\`, \`measurement\`, \`mmp\`. When omitted, returns all types. +- \`startHour\` (string, optional): Start of query range (inclusive), hour-aligned ISO 8601 (e.g. \`2026-03-27T14:00:00Z\`). Defaults to start of last completed UTC hour. +- \`endHour\` (string, optional): End of query range (exclusive), hour-aligned ISO 8601. Defaults to end of last completed UTC hour. + +**Response (200):** +\`\`\`json { - "name": "Purchase", - "type": "PURCHASE", - "description": "Completed purchase event", - "value": 100, - "currency": "USD" + "data": { + "periodStart": "2026-03-27T14:00:00.000Z", + "periodEnd": "2026-03-27T15:00:00.000Z", + "entries": [ + { + "eventHour": "2026-03-27T14:00:00.000Z", + "eventType": "impression", + "eventCount": 1500 + }, + { + "eventHour": "2026-03-27T14:00:00.000Z", + "eventType": "conversion", + "eventCount": 25 + } + ], + "totalEventCount": 1525 + } } \`\`\` -**Event Types:** \`PURCHASE\`, \`SIGNUP\`, \`LEAD\`, \`PAGE_VIEW\`, \`ADD_TO_CART\`, \`CUSTOM\` +The response includes both advertiser-specific events and customer-level shared events (events not tied to a specific advertiser but shared across all advertisers under the same customer). + +--- + +### Log Event -#### Get Conversion Event +Log conversion and marketing events for attribution. Events are forwarded to the tracking endpoint (CAPI). Requires an event source registered via sync_event_sources. + +#### Log Events \`\`\`http -GET /api/v2/buyer/advertisers/{advertiserId}/conversion-events/{id} +POST /api/v2/buyer/advertisers/{advertiserId}/log-event +{ + "event_source_id": "website_pixel", + "events": [ + { + "event_id": "txn_abc123", + "event_type": "purchase", + "event_time": "2026-03-15T14:30:00-05:00", + "action_source": "website", + "event_source_url": "https://example.com/checkout", + "user_match": { + "hashed_email": "a1b2c3d4e5f6...", + "click_id": "abc123", + "click_id_type": "gclid" + }, + "custom_data": { + "value": 99.99, + "currency": "USD", + "order_id": "order_456", + "content_ids": ["prod_789"], + "num_items": 2 + } + } + ], + "test_event_code": "TEST123" +} +\`\`\` + +**Request body:** +- \`event_source_id\` (string, required): Event source registered via sync_event_sources +- \`events\` (array, required, 1–10,000 items): Events to log +- \`test_event_code\` (string, optional): Test code for validation without affecting production data + +**Each event object:** +- \`event_id\` (string, required): Unique identifier for deduplication +- \`event_type\` (enum, required): \`purchase\`, \`lead\`, \`add_to_cart\`, \`initiate_checkout\`, \`view_content\`, \`complete_registration\`, \`page_view\`, \`app_install\`, \`deposit\`, \`subscription\`, \`custom\` +- \`event_time\` (string, required): When the event occurred (ISO 8601 with timezone) +- \`action_source\` (enum, optional): \`website\`, \`app\`, \`in_store\`, \`phone_call\`, \`system_generated\`, \`other\` +- \`event_source_url\` (string, optional): URL where the event occurred +- \`custom_event_name\` (string, optional): Name for custom events (when event_type is \`custom\`) +- \`user_match\` (object, optional): User identity for attribution matching + - \`uids\` (array): Universal ID values (\`{type, value}\` — rampid, id5, uid2, euid, pairid, maid) + - \`hashed_email\`: SHA-256 of lowercase trimmed email + - \`hashed_phone\`: SHA-256 of E.164 phone number + - \`click_id\` / \`click_id_type\`: Platform click identifier + - \`client_ip\` / \`client_user_agent\`: For probabilistic matching +- \`custom_data\` (object, optional): Event-specific data + - \`value\`: Monetary value + - \`currency\`: ISO 4217 code (e.g. \`USD\`) + - \`order_id\`: Transaction identifier + - \`content_ids\`: Product identifiers + - \`content_type\`: Category (product, service, etc.) + - \`num_items\`: Item count + - \`contents\`: Array of \`{id, quantity, price, brand}\` + +**Response (200):** +\`\`\`json +{ + "data": { + "events_received": 1, + "events_processed": 1, + "partial_failures": [], + "warnings": [], + "match_quality": 0.85 + } +} \`\`\` -#### Update Conversion Event +--- + +### Measurement Data + +Sync advertiser performance measurement data as an alternative to CAPI. Accepts time-series metric data over date ranges keyed by campaign, media buy, package, and/or creative. Uses upsert semantics — re-submitting the same data is safe and idempotent. + +#### Sync Measurement Data + \`\`\`http -PUT /api/v2/buyer/advertisers/{advertiserId}/conversion-events/{id} +POST /api/v2/buyer/advertisers/26/measurement-data/sync +{ + "measurements": [ + { + "start_time": "2026-03-01T00:00:00-05:00", + "end_time": "2026-03-07T23:59:59-05:00", + "metric_id": "incremental_revenue", + "metric_value": 8450.75, + "unit": "currency", + "currency": "USD", + "campaign_id": "camp_456" + } + ] +} +\`\`\` + +**URL path:** \`/advertisers/{advertiserId}/measurement-data/sync\` — the \`{advertiserId}\` is the numeric advertiser ID (e.g. \`26\`). + +**\`measurements\` array (required, 1–1000 items). Each object:** +- \`start_time\` (string, required): Start of the measurement period (ISO 8601 with timezone) +- \`end_time\` (string, required): End of the measurement period (ISO 8601 with timezone, must be after start_time) +- \`metric_id\` (enum, required): \`revenue\`, \`incremental_revenue\`, \`conversions\`, \`incremental_conversions\`, \`page_view_count\`, \`add_to_cart_count\`, \`purchase_count\`, \`ltv_1d\`, \`ltv_7d\`, \`ltv_30d\` +- \`metric_value\` (number, required): Measured value for this metric +- \`unit\` (enum, required): \`currency\`, \`count\`, \`ratio\`, \`percentage\` +- \`currency\` (string, conditional): 3-letter uppercase ISO 4217 code (e.g. \`"USD"\`) — required when \`unit\` is \`"currency"\` +- \`advertiser_id\` (string, optional): Advertiser identifier +- \`campaign_id\` (string, optional): Campaign identifier +- \`media_buy_id\` (string, optional): Media buy identifier +- \`package_id\` (string, optional): Package identifier +- \`creative_id\` (string, optional): Creative identifier +- \`source\` (string, optional): Source of the measurement data +- \`source_platform\` (string, optional): Platform the data originates from +- \`external_row_id\` (string, optional): External row identifier for idempotency + +**Constraint:** At least one of \`advertiser_id\`, \`campaign_id\`, \`media_buy_id\`, \`package_id\`, or \`creative_id\` must be provided. + +**Response (200):** +\`\`\`json { - "name": "High-Value Purchase", - "value": 200 + "data": { + "measurements": [ + { "index": 0, "action": "created" } + ] + } } \`\`\` +Actions: \`created\`, \`updated\`, \`unchanged\`, \`failed\` + --- ### Test Cohorts @@ -1033,37 +1612,127 @@ POST /api/v2/buyer/advertisers/{advertiserId}/test-cohorts --- -### Creative Sets +### Creative Manifests (Campaign-Scoped) -#### List Creative Sets -\`\`\`http -GET /api/v2/buyer/advertisers/{advertiserId}/creative-sets +Creative manifests live under campaigns — they are always scoped to a specific campaign. + +**Dashboard URL**: \`{dashboard_url}\` is the base URL returned by \`GET /api/v2/buyer/dashboard-url\`. You MUST call this endpoint and use the returned value — never hardcode or guess the URL. It varies by environment (e.g. \`http://localhost:5173\` in local dev, \`https://agentic.scope3.com\` in production). + +--- + +#### ⚠️⚠️⚠️ ABSOLUTE RULE: Creative Asset Creation & Upload = UI ONLY ⚠️⚠️⚠️ + +**There is NO MCP tool or API call for creating manifests or uploading assets. These operations DO NOT EXIST in the MCP interface.** + +When a buyer asks to **add**, **upload**, **create**, or **manage** creative assets, follow these steps **exactly**: + +**Step 1:** Get the dashboard URL: +\`\`\` +GET /api/v2/buyer/dashboard-url \`\`\` +Returns: \`{ "dashboard_url": "" }\` -#### Create Creative Set -\`\`\`http -POST /api/v2/buyer/advertisers/{advertiserId}/creative-sets -{ - "name": "Q1 Video Creatives", - "type": "video" -} +**Step 2:** Look up the advertiser ID and campaign ID using the **existing** list endpoints: +- \`GET /api/v2/buyer/advertisers\` — find the advertiser by name +- \`GET /api/v2/buyer/campaigns?advertiserId={advertiserId}\` — find the campaign by name + +**Step 3:** Return the fully-qualified link: +\`\`\` +{dashboard_url}/campaign-creative-assets/{advertiserId}/{campaignId} \`\`\` -#### Add Asset to Creative Set -\`\`\`http -POST /api/v2/buyer/advertisers/{advertiserId}/creative-sets/{creativeSetId}/assets -{ - "assetUrl": "https://example.com/video.mp4", - "name": "Hero Video 30s", - "type": "video", - "duration": 30 -} +**That is your ENTIRE response. Nothing else.** + +**Examples:** + +Buyer: "I want to add some creative assets for my Q2 campaign" +→ Call \`GET /api/v2/buyer/dashboard-url\` → get the \`dashboard_url\` value from the response +→ Call \`GET /api/v2/buyer/advertisers\` → find advertiser ID +→ Call \`GET /api/v2/buyer/campaigns?advertiserId=24\` → find "Q2" campaign ID +→ Reply: "You can manage your creative assets here: {dashboard_url}/campaign-creative-assets/24/campaign_abc123" + +Buyer: "For Ematini | Q2 Sales Boost I want to add some creative assets" +→ Call \`GET /api/v2/buyer/dashboard-url\` → get the \`dashboard_url\` value from the response +→ Call \`GET /api/v2/buyer/advertisers\` → find Ematini (ID 24) +→ Call \`GET /api/v2/buyer/campaigns?advertiserId=24\` → find Q2 Sales Boost campaign +→ Reply: "You can manage your creative assets here: {dashboard_url}/campaign-creative-assets/24/campaign_1770084484376_r7pspq" + +**DO NOT:** +- Ask what files they have +- Ask about briefs, URLs, or tracking pixels +- Mention any API endpoint to the buyer +- Explain what asset types are supported +- Offer to create a manifest via API (this is impossible via MCP) +- Mention \`POST .../creatives/create\` (that is a REST-only endpoint used by the UI, not available via MCP) +- Use \`ask_about_capability\` — just call the endpoints above directly + +--- + +#### What IS Available via MCP (READ-ONLY + metadata updates on EXISTING manifests) + +**REMINDER: You CANNOT create new manifests via MCP. If the buyer asks to "add", "create", or "upload" creatives, you MUST direct them to the UI link (see above). The operations below ONLY work on manifests that already exist.** + +The MCP interface exposes these operations for creative manifests that were **already created in the UI**: + +**Note:** Template detection and format matching happen automatically when assets are uploaded via the UI. The response includes \`auto_detected_template\` with the detected \`template_id\` and detection \`method\`. There is no need to call a separate templates endpoint. + +##### 1. List Creative Manifests +\`\`\` +GET /api/v2/buyer/campaigns/{campaignId}/creatives \`\`\` +**Query parameters:** +- \`quality\` (optional): Filter by quality level +- \`search\` (optional): Case-insensitive name search +- \`take\` (optional): Page size, default 50 +- \`skip\` (optional): Pagination offset -#### Remove Asset -\`\`\`http -DELETE /api/v2/buyer/advertisers/{advertiserId}/creative-sets/{creativeSetId}/assets/{assetId} +**Response:** Array of manifests, each with \`creative_id\`, \`name\`, \`message\` (brief), \`template_id\`, \`format_id\`, \`preview_url\`, \`format_previews[]\` (concrete format sizes), \`auto_detected_template\`, \`assets[]\` (each with \`asset_source\`: \`CREATIVE_SOURCE\`, \`USER_UPLOADED\`, or \`SYSTEM_PROCESSED\`), \`campaign_id\`, \`created_at\`, \`updated_at\`. + +##### 2. Get Creative Manifest +\`\`\` +GET /api/v2/buyer/campaigns/{campaignId}/creatives/{creativeId} +\`\`\` +Optional query: \`?preview=true\` + +**Response:** Single manifest with all fields including \`preview_url\`, \`format_previews[]\`, \`auto_detected_template\`, \`html_processing\` (macros injected, unresolved refs), and assets array. + +##### 3. Update Creative Manifest (metadata only — NO file uploads) +\`\`\` +PUT /api/v2/buyer/campaigns/{campaignId}/creatives/{creativeId} +\`\`\` +**Body (all fields optional):** +- \`name\` (string): Manifest name +- \`message\` (string): Creative brief +- \`tag\` (string): Tag +- \`quality\` (string): Quality level +- \`format_id\` (object): \`{ agent_url: string, id: string }\` — ADCP format ID +- \`template_id\` (string): ADCP format template ID (e.g. \`"display_300x250_html"\`, \`"video_standard"\`, \`"vendor_dcm_tag"\`) +- \`url_asset\` (object): \`{ url: string, url_type: string }\` — add a URL-based asset +- \`delete_asset_ids\` (string[]): Asset IDs to soft-delete +- \`reclassify_assets\` (array): \`[{ asset_id: string, asset_type: string }]\` — change asset type + +**Note:** File uploads are NOT possible via MCP. The MCP update only handles metadata changes, URL assets, and asset deletion/reclassification. For file uploads, direct the buyer to the UI. + +##### 4. Delete Creative Manifest \`\`\` +DELETE /api/v2/buyer/campaigns/{campaignId}/creatives/{creativeId} +\`\`\` +Soft-deletes the manifest and all its assets (sets \`archived_at\`). + +**Response:** \`204 No Content\` + +**⚠️ FINAL REMINDER: None of the above operations CREATE a manifest. If the buyer wants to ADD or CREATE creatives, your ONLY response is the UI link: \`{dashboard_url}/campaign-creative-assets/{advertiserId}/{campaignId}\`. Do NOT ask follow-up questions about file types, URLs, briefs, or tags.** + +--- + +#### Display Requirements — When listing/showing manifests: +- **Name** and **Creative ID** +- **Template** and **Format ID** (if set) — these are ADCP format IDs (e.g. \`display_300x250_html\`, \`video_standard\`) +- **Auto-detected template** (if present) — show \`template_id\` and detection \`method\` +- **Brief/message** (if present) +- **Format previews count** (if present) — e.g. "4 format sizes available" +- **Asset count** and asset details (filename, type, \`asset_source\`, URL) +- **Created/Updated timestamps** --- @@ -1087,6 +1756,19 @@ Returns reporting data in one of two views: **summary** (hierarchical breakdown) - \`campaignId\` (optional): Filter by campaign ID - \`demo\` (optional, boolean): When \`true\`, returns auto-generated demo data instead of querying real data sources. Default: \`false\`. Useful for testing and previewing the reporting UI without live campaign data. +**⚠️ CRITICAL: Disambiguating "demo" in user requests** + +The word "demo" can mean two different things in reporting requests. You MUST distinguish between them: + +| User intent | Example phrases | Action | +|-------------|----------------|--------| +| **Demo flag** (synthetic demo data) | "show reporting (demo)", "demo show reporting", "show reporting with demo flag", "show reporting demo mode" | Set \`demo=true\` query parameter | +| **Name filter** (advertiser/campaign containing "demo") | "show reporting for demo advertiser", "show reporting for % demo %", "show campaigns named demo", "reporting for 'demo brand'" | Use \`advertiserId\` or \`campaignId\` filters to match entities whose names contain "demo" — do NOT set \`demo=true\` | + +**How to tell the difference:** +- If "demo" appears as a **modifier or flag on the reporting request itself** (in parentheses, as "demo mode", "demo flag", or as a standalone qualifier adjacent to "reporting"), the user wants \`demo=true\`. +- If "demo" appears as a **value describing an advertiser, campaign, or entity name** (preceded by "for", "named", "called", or wrapped in quotes/wildcards), the user is filtering by name — do NOT set \`demo=true\`. + **Summary Response** (\`view=summary\`, default): \`\`\`json { @@ -1142,25 +1824,224 @@ Returns reporting data in one of two views: **summary** (hierarchical breakdown) **Metrics included:** impressions, spend, clicks, views, completedViews, conversions, leads, videoCompletions, ecpm, cpc, ctr, completionRate +#### Export / Download Reporting Metrics as CSV + +To export or download reporting data as a CSV file, use the same reporting metrics endpoint with \`?download=true\`. This generates a CSV and returns a signed download URL (valid 7 days) instead of JSON data. + +**IMPORTANT:** When a user asks to "export", "download", "save as CSV", or "get a spreadsheet" of their reporting data, use this endpoint with \`download=true\`. + +\`\`\`http +GET /api/v2/buyer/reporting/metrics?days=30&download=true +\`\`\` + +All the same query parameters apply (\`days\`, \`startDate\`, \`endDate\`, \`advertiserId\`, \`campaignId\`). The only addition is \`download=true\`. + +**Response when \`download=true\`:** +\`\`\`json +{ + "downloadUrl": "https://storage.googleapis.com/...", + "expiresAt": "2025-02-20T12:00:00.000Z", + "fileName": "reporting-metrics-2025-02-06-to-2025-02-13.csv", + "rowCount": 42 +} +\`\`\` + +**CSV columns (20):** Advertiser ID, Advertiser Name, Campaign ID, Campaign Name, Media Buy ID, Media Buy Name, Media Buy Status, Package ID, Impressions, Spend, Clicks, Views, Completed Views, Conversions, Leads, Video Completions, eCPM, CPC, CTR, Completion Rate. One row per package (media buys with no packages get one row with empty Package ID). + +**CRITICAL: NEVER generate your own CSV, Excel, or spreadsheet files.** Always use this \`?download=true\` endpoint to produce reporting exports. The endpoint handles proper formatting, escaping, and data integrity. Do not use artifacts, code execution, or any other mechanism to create files — use the API. + +When the download response is received, present the \`downloadUrl\` to the user as a clickable download link. Include the \`fileName\` and note that the link expires after 7 days (\`expiresAt\`). + +--- + +### Catalogs + +Catalogs are managed entirely through sync — there is no separate create/update/delete. + +**Before calling sync, you MUST collect from the user:** +1. Which advertiser — the advertiser ID goes in the URL path AND in \`account.account_id\` in the request body +2. The catalog(s) to sync — each needs a \`catalog_id\`, \`type\`, and either a feed \`url\` or inline \`items\` + +#### Sync Catalogs + +**Option A — Remote feed URL (multiple catalogs in one call):** +\`\`\`http +POST /api/v2/buyer/advertisers/26/catalogs/sync +{ + "account": { "account_id": "26" }, + "catalogs": [ + { + "catalog_id": "products-2026", + "type": "product", + "name": "2026 Product Catalog", + "url": "https://example.com/products.xml", + "feed_format": "google_merchant_center", + "update_frequency": "daily" + }, + { + "catalog_id": "promotions-q1", + "type": "promotion", + "name": "Q1 Promotions", + "url": "https://example.com/promotions.xml", + "feed_format": "custom", + "update_frequency": "hourly" + } + ] +} +\`\`\` + +**Option B — Inline items:** +\`\`\`http +POST /api/v2/buyer/advertisers/26/catalogs/sync +{ + "account": { "account_id": "26" }, + "catalogs": [ + { + "catalog_id": "my-catalog-1", + "type": "product", + "name": "Q1 Products", + "items": [ + { + "item_id": "sku-001", + "title": "Blue Widget", + "description": "A sturdy blue widget for everyday use", + "price": "19.99 USD", + "link": "https://example.com/products/blue-widget", + "image_link": "https://example.com/images/blue-widget.jpg", + "availability": "in stock", + "brand": "Acme", + "google_product_category": "Hardware > Tools" + }, + { + "item_id": "sku-002", + "title": "Red Widget", + "description": "A sturdy red widget for everyday use", + "price": "24.99 USD", + "link": "https://example.com/products/red-widget", + "image_link": "https://example.com/images/red-widget.jpg", + "availability": "in stock", + "brand": "Acme", + "google_product_category": "Hardware > Tools" + } + ] + } + ] +} +\`\`\` + +> Items are free-form key/value objects — the fields depend on the catalog \`type\`. The examples above use common product fields. For \`job\` catalogs use fields like \`job_id\`, \`title\`, \`company\`, \`location\`; for \`hotel\` use \`hotel_id\`, \`name\`, \`address\`, \`star_rating\`; etc. + +**URL path:** \`/advertisers/{advertiserId}/catalogs/sync\` — the \`{advertiserId}\` is the numeric advertiser ID (e.g. \`26\`). Also include it in the request body as \`account.account_id\`. + +**\`account\` (required in body):** +- \`account_id\` (string): The advertiser ID — same value as the path \`{advertiserId}\` (e.g. \`"26"\`). + +**\`catalogs\` array (required, 1–50 items). Each object:** +- \`catalog_id\` (string, required): Buyer-assigned identifier +- \`type\` (string, required): \`offering\`, \`product\`, \`inventory\`, \`store\`, \`promotion\`, \`hotel\`, \`flight\`, \`job\`, \`vehicle\`, \`real_estate\`, \`education\`, \`destination\` +- \`name\` (string, optional): Display name +- \`url\` (string): Remote feed URL — provide this OR \`items\`, not both +- \`items\` (array): Inline catalog items — provide this OR \`url\`, not both +- \`feed_format\` (string, optional): \`google_merchant_center\`, \`facebook_catalog\`, \`shopify\`, \`linkedin_jobs\`, \`custom\` +- \`update_frequency\` (string, optional): \`realtime\`, \`hourly\`, \`daily\`, \`weekly\` +- \`conversion_events\` (array, optional): Conversion event IDs + +**Other optional fields:** +- \`catalog_ids\` (array): Filter which catalog_ids from the \`catalogs\` array to process +- \`delete_missing\` (boolean): Archive catalogs not included in this request (default: false) +- \`dry_run\` (boolean): Preview changes without persisting (default: false) +- \`validation_mode\` (string): \`strict\` (default) or \`lenient\` + +**Response:** +\`\`\`json +{ + "data": { + "results": [ + { + "catalog_id": "my-catalog-1", + "action": "created", + "name": "Q1 Products", + "type": "offering" + } + ] + } +} +\`\`\` + +Actions: \`created\`, \`updated\`, \`unchanged\`, \`failed\`, \`deleted\` + +#### List Catalogs + +\`\`\`http +GET /api/v2/buyer/advertisers/26/catalogs +\`\`\` + +**URL path:** \`/advertisers/{advertiserId}/catalogs\` — the \`{advertiserId}\` is the numeric advertiser ID. + +**Query Parameters:** +- \`type\` (optional): Filter by catalog type (\`offering\`, etc.) +- \`take\` / \`skip\` (optional): Pagination + +**Response (200):** +\`\`\`json +{ + "data": { + "account": { "account_id": "26" }, + "catalogs": [ + { + "catalogId": "my-catalog-1", + "type": "offering", + "name": "Q1 Products", + "url": "https://example.com/feed.xml" + } + ] + } +} +\`\`\` + --- ### Sales Agents -Browse available sales agents and register accounts to connect with them. +Browse available sales agents, register agent credentials, and link accounts to advertisers. + +**CRITICAL — Account Status Awareness:** +When listing sales agents, you MUST tell the user about the credential/account registration status for EACH agent. Specifically: +- If \`requiresAccount\` is \`true\`: Tell the user they need to register credentials for this agent before they can use it. +- If \`customerAccounts\` is empty and \`requiresOperatorAuth\` is \`true\`: The customer has NOT set up credentials yet — flag this. +- If \`customerAccounts\` has entries: Show the user their registered account identifiers and statuses. +- If \`requiresOperatorAuth\` is \`false\`: The platform handles credentials — no action needed from the user. + +Never silently omit this information. The user needs to know which agents are ready to use and which require setup. + +**CRITICAL — Proactive Registration Prompt for \`requiresAccount: true\` Agents:** +When ANY agent in the response has \`requiresAccount: true\`, you MUST: +1. **Explicitly call it out in a separate section** — Do NOT bury it in the main list. After showing the full agent list, add a clear callout like: "The following agents require you to register credentials before you can use them: [agent names]. Would you like to register credentials for any of them?" +2. **Offer to start the registration flow** — Ask the user if they want to register credentials now. If yes: + a. For **OAUTH agents** (\`authenticationType: "OAUTH"\`): Call \`POST /api/v2/buyer/sales-agents/{agentId}/accountCredentials\` with just \`accountIdentifier\` (no \`auth\` field). Present the returned \`oauth.authorizationUrl\` for the user to complete authorization. + b. For **API_KEY/JWT agents**: Ask the user for their credentials, then call \`POST /api/v2/buyer/sales-agents/{agentId}/accountCredentials\`. +3. **After registering credentials, offer to link an account to an advertiser** — Once agent credentials are registered, ask: "Now that credentials are set up for [agent name], would you like to discover and link an account to a specific advertiser?" If yes, follow the Link Agent Account to Advertiser workflow (see Advertisers section): + a. List the customer's advertisers via \`GET /api/v2/buyer/advertisers\` + b. For the chosen advertiser, discover available accounts for the agent: \`GET /api/v2/buyer/advertisers/{advertiserId}/accounts/available?partnerId={agentId}\` + c. Present discovered accounts and let the user pick + d. Link via \`PUT /api/v2/buyer/advertisers/{advertiserId}\` with \`linkedAccounts\` + +This end-to-end flow (list agents → register agent credentials → link account to advertiser) should feel seamless. Do NOT make the user figure out the next step — always offer it. #### List Sales Agents List all sales agents visible to the buyer. Shows all non-DISABLED agents. Results are paginated. \`\`\`http -GET /api/v2/buyer/sales-agents?status=ACTIVE&relationship=MARKETPLACE&limit=20&offset=0 +GET /api/v2/buyer/sales-agents?status=ACTIVE&relationship=MARKETPLACE&limit=10&offset=0 \`\`\` **Query Parameters (all optional):** - \`status\` (string): Filter by status — \`PENDING\`, \`ACTIVE\` - \`relationship\` (string): Filter by relationship — \`SELF\` (owned by you), \`MARKETPLACE\` (all other marketplace agents) - \`name\` (string): Filter by agent name (partial match, case-insensitive) -- \`limit\` (number): Maximum number of agents to return per page (default: 20) +- \`supportsRegistration\` (boolean string \`"true"\`/\`"false"\`): When \`true\`, return only agents with \`requiresOperatorAuth: true\` (i.e. agents the buyer must register their own credentials for) +- \`limit\` (number): Maximum number of agents to return per page (default: 10, max: 10) - \`offset\` (number): Number of agents to skip for pagination (default: 0) **Response:** @@ -1176,7 +2057,7 @@ GET /api/v2/buyer/sales-agents?status=ACTIVE&relationship=MARKETPLACE&limit=20&o "endpointUrl": "https://api.premiumvideo.com/adcp", "protocol": "MCP", "authenticationType": "API_KEY", - "accountPolicy": ["advertiser_account", "marketplace_account"], + "requiresOperatorAuth": true, "status": "ACTIVE", "relationship": "MARKETPLACE", "customerAccounts": [ @@ -1191,49 +2072,103 @@ GET /api/v2/buyer/sales-agents?status=ACTIVE&relationship=MARKETPLACE&limit=20&o } ], "total": 25, - "hasMore": true + "hasMore": true, + "nextOffset": 10 } } \`\`\` -**Pagination:** Use \`offset\` + \`limit\` to page through results. When \`hasMore\` is true, fetch the next page with \`offset\` increased by \`limit\`. +**Pagination:** Results are paginated with a maximum of 10 items per page. When \`hasMore\` is true, use the \`nextOffset\` value as the \`offset\` parameter in your next request to fetch the next page. Continue until \`hasMore\` is false or \`nextOffset\` is null. **Notes:** - All non-DISABLED agents are visible to everyone - PENDING agents from other owners appear as \`"status": "COMING_SOON"\` with minimal info - \`customerAccounts\` lists the caller's own accounts (excludes marketplace accounts) -- \`requiresAccount\` is true when the agent supports per-account registration and the caller has no accounts -- \`accountPolicy\` shows the agent's allowed account types +- \`requiresAccount\` is true when the agent requires operator auth and the caller has no credentials registered +- \`requiresOperatorAuth\` indicates whether the buyer must provide their own credentials - \`oauth\` field is present for owner's PENDING OAUTH agents that haven't completed the OAuth flow +**Credential rules — CRITICAL:** +- \`requiresOperatorAuth: true\` → the buyer MUST provide their own credentials. Use \`GET /api/v2/buyer/sales-agents?supportsRegistration=true\` to list these. Registration is done via \`POST /api/v2/buyer/sales-agents/{agentId}/accountCredentials\`. +- \`requiresOperatorAuth: false\` → the platform (Scope3) uses its own credentials. **Individual credential registration is NOT applicable.** Do NOT present these agents when the user asks which agents they can register credentials for or which agents they can link to advertisers. +- These states are mutually exclusive — an agent is in one state or the other, never both. +- **Account linking requires \`requiresOperatorAuth: true\`.** Only agents where the buyer registers their own credentials can have accounts discovered and linked to advertisers. When users ask about linking agents to advertisers, ALWAYS filter with \`supportsRegistration=true\`. + **Display Requirements — ALWAYS include when listing sales agents:** -- \`accountPolicy\`: Show what account types each agent supports (advertiser_account, marketplace_account) -- \`customerAccounts\`: Show the caller's registered accounts for each agent -- \`requiresAccount\`: Highlight agents that require account registration -- Group agents by status (ACTIVE first, then COMING_SOON) -- For each agent, clearly indicate: name, status, account policy, number of caller's accounts, and whether registration is needed -#### Register Sales Agent Account +Present each agent as a structured entry (not prose). Group agents into these sub-categories: + +1. **Active & Ready to Use** — ACTIVE agents where \`requiresAccount\` is false +2. **Active but Requires Your Credentials** — ACTIVE agents where \`requiresAccount\` is true +3. **Coming Soon** — agents with status \`COMING_SOON\` + +For every agent, show: +- **Name** (append "[Your Agent]" if \`relationship: "SELF"\`) +- **ID** (\`agentId\`) +- **Protocol** (MCP, A2A, etc.) +- **Status** (ACTIVE, COMING_SOON, etc.) +- **Credential status** — one of: "Platform-managed" (if \`requiresOperatorAuth: false\`), "Registered" (if \`requiresOperatorAuth: true\` and \`customerAccounts\` has entries), or "Needs your credentials" (if \`requiresOperatorAuth: true\` and no \`customerAccounts\`) +- **Registered accounts** — list each \`customerAccounts\` entry by \`accountIdentifier\` and \`status\`, or "None" if empty + +Key rules: +- \`status\` and \`requiresAccount\` are separate concepts. \`status\` = whether the agent is live. \`requiresAccount\` = whether this buyer has registered credentials. +- Never summarize into a sentence like "You have 5 sales agents." Always show the per-item details above for every agent. +- **After listing, ALWAYS ask about registration** if any agents have \`requiresAccount: true\`. Do NOT just list them and move on. +- When answering "which agents can I register credentials for" or "which agents can I link to advertisers" (or any variation about linking, connecting, or registering with agents) — call \`GET /api/v2/buyer/sales-agents?supportsRegistration=true\` to get only agents that support account registration and linking. Only agents with \`requiresOperatorAuth: true\` can have accounts linked to advertisers — agents with \`requiresOperatorAuth: false\` use platform-managed credentials and do NOT support per-advertiser account linking. Do NOT call without this parameter and filter client-side. + +#### List Registered Agent Credentials -Register an account for a sales agent under a specific advertiser. This connects the buyer's advertiser to the agent. +List all agent credentials registered by this customer across all agents. \`\`\`http -POST /api/v2/buyer/sales-agents/{agentId}/accounts -{ - "advertiserId": "300", - "accountIdentifier": "my-publisher-account", - "auth": { - "type": "bearer", - "token": "my-api-key" - } -} +GET /api/v2/buyer/sales-agents/accountCredentials \`\`\` -**Path Parameters:** +**Response (200):** +\`\`\`json +{ + "data": [ + { + "id": "722", + "agentId": "snap_6e2d13705a26", + "agentName": "Snap", + "accountIdentifier": "Scope3 Snap Creds", + "accountType": "CLIENT", + "status": "ACTIVE", + "registeredBy": "user@example.com", + "createdAt": "2026-02-23T19:55:11.602Z", + "updatedAt": "2026-02-23T19:56:56.272Z" + } + ] +} +\`\`\` + +**Notes:** +- Returns all agent credentials belonging to the authenticated customer, across all agents +- \`auth_configuration\` is never returned (sensitive) +- Use this to check what agent credentials have been registered before linking accounts to advertisers + +#### Register Agent Credentials + +Register credentials for a specific agent at the **customer level**. This is the first step in connecting to an agent — credentials belong to the whole customer, not a specific advertiser. Once credentials are registered, accounts can be discovered and linked to individual advertisers (see Link Agent Account to Advertiser in the Advertisers section). + +**Multiple credentials per agent:** A customer CAN register multiple sets of credentials for the same agent (e.g., two different Snap ad accounts with different API keys). Each set uses a different \`accountIdentifier\`. Each credential discovers its own set of ad accounts. When discovering accounts for linking, the \`credentialId\` parameter is required so the system knows which credential to query — see "Link Agent Account to Advertiser" in the Advertisers section for the full workflow. + +\`\`\`http +POST /api/v2/buyer/sales-agents/{agentId}/accountCredentials +{ + "accountIdentifier": "my-publisher-account", + "auth": { + "type": "bearer", + "token": "my-api-key" + } +} +\`\`\` + +**Path Parameters:** - \`agentId\` (string): The agent ID **Required Fields:** -- \`advertiserId\` (string): The advertiser seat ID (BUYER seat) to connect this account for - \`accountIdentifier\` (string): Unique account identifier for this agent **Optional Fields:** @@ -1274,664 +2209,823 @@ POST /api/v2/buyer/sales-agents/{agentId}/accounts - For OAUTH agents, the account is created with PENDING status and includes an \`authorizationUrl\` for the user to click - After the user authorizes, the account status changes to ACTIVE automatically ---- +### Audiences -### Signals (for Audience Campaigns) +Sync first-party CRM audiences into Scope3 for later syndication to sales agents. Audiences contain hashed customer identifiers used for targeting. Processing is **asynchronous** — sync returns immediately with an \`operationId\`, and processing completes in the background. -#### Discover Signals -\`\`\`http -POST /api/v2/buyer/campaign/signals/discover -\`\`\` +**Important:** All member identifiers must be pre-hashed before sending: +- **Email:** SHA-256 of lowercase, trimmed email (64-char hex string) +- **Phone:** SHA-256 of E.164-formatted phone number (64-char hex string) +- **Universal IDs:** RampID, UID2, MAID, etc. passed as-is -Discover available signals for audience targeting. Audience campaigns are not yet implemented (returns 501), but you can browse available signals. +**Limits:** Maximum 100,000 total members per sync call. For larger lists, chunk into sequential requests. -#### List Saved Signals -\`\`\`http -GET /api/v2/buyer/signals -\`\`\` +#### Sync Audiences -Returns signals that have been saved to your account. +Sync audience data for an advertiser. The \`accountId\` in the URL is the **advertiser ID** (numeric, e.g. \`25\`) — the same \`advertiserId\` used when creating campaigns. Returns **202 Accepted** with an operation ID for tracking. Each member requires an \`externalId\` plus at least one hashed identifier. ---- +\`\`\`http +POST /api/v2/buyer/advertisers/{accountId}/audiences/sync +{ + "audiences": [ + { + "audienceId": "crm-high-value", + "name": "High Value Customers", + "add": [ + { + "externalId": "user-001", + "hashedEmail": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2" + }, + { + "externalId": "user-002", + "uids": [{ "type": "uid2", "value": "uid2-token-value" }] + } + ], + "consentBasis": "consent" + } + ], + "deleteMissing": false +} +\`\`\` -## Error Handling +**Path Parameters:** +- \`accountId\` (string, required): Advertiser ID (numeric, e.g. \`"25"\`) -All errors follow this format: +**Required Fields:** +- \`audiences\` (array): Audiences to sync + - \`audienceId\` (string, required): Buyer's identifier for this audience + +**Optional Fields per Audience:** +- \`name\` (string): Human-readable name +- \`add\` (array, max 10,000): Members to add (each needs \`externalId\` + at least one identifier) +- \`remove\` (array, max 10,000): Members to remove by \`externalId\` +- \`delete\` (boolean): When true, delete this audience entirely +- \`consentBasis\` (string): GDPR lawful basis — \`consent\`, \`legitimate_interest\`, \`contract\`, \`legal_obligation\` +- \`deleteMissing\` (boolean): When true, audiences not in this request are marked as deleted + +**Response (202 Accepted):** \`\`\`json { - "data": null, - "error": { - "code": "VALIDATION_ERROR", - "message": "Human-readable message", - "details": {} - } + "success": true, + "accountId": "25", + "operationId": "550e8400-e29b-41d4-a716-446655440000", + "taskId": "550e8400-e29b-41d4-a716-446655440000" } \`\`\` -| Code | HTTP Status | Resolution | -|------|-------------|------------| -| \`VALIDATION_ERROR\` | 400 | Check request body against schema | -| \`UNAUTHORIZED\` | 401 | Verify API key/auth | -| \`ACCESS_DENIED\` | 403 | Check permissions | -| \`NOT_FOUND\` | 404 | Verify resource ID exists | -| \`CONFLICT\` | 409 | Resource already exists (e.g., brand) | -| \`RATE_LIMITED\` | 429 | Wait and retry | - ---- +**Notes:** +- Processing is asynchronous — poll \`GET /api/v2/buyer/tasks/{taskId}\` for progress (see [Tasks](#tasks)) +- \`status\` values: \`PROCESSING\` (matching in progress), \`READY\` (available for targeting), \`ERROR\`, \`TOO_SMALL\` (below platform minimum) -## Common Mistakes to Avoid +#### List Audiences -1. **Creating campaign without advertiser** — Always create/verify advertiser first -2. **Discovery campaign without bundleId** — You MUST create a bundle first via \`POST /bundles\` or \`POST /bundles/discover-products\` -3. **Skipping product selection** — You MUST add products to the bundle via \`POST /bundles/{id}/products\` BEFORE creating a discovery campaign -4. **Expecting products from POST /bundles** — \`POST /bundles\` only returns bundleId; use \`GET /bundles/{id}/discover-products\` to get product suggestions -5. **Performance campaign without conversion events** — System needs events to optimize -6. **Adding products to performance campaigns** — Performance campaigns handle inventory selection automatically; don't pass product/signal selections -7. **Forgetting to execute** — Campaigns start in DRAFT status; must call \`POST /campaigns/{id}/execute\` -8. **Wrong endpoint path** — Always use \`/api/v2/buyer/\` prefix -9. **Creating advertiser without brandDomain** — \`brandDomain\` is required. If brand resolution fails, tell the user to register their brand at https://adcontextprotocol.org/chat.html or https://agenticadvertising.org/brand first -10. **Choosing performance when user wants to browse/select inventory** — If the user wants to see products or pick publishers, that's **discovery**, not performance -11. **Defaulting to a campaign type without asking** — When the user says "create a campaign" without specifying a type, ALWAYS ask them to choose -12. **Fabricating field values** — NEVER guess or make up values for required fields. Always ask the user or use values from previous API responses -`; +List stored audiences for an account. Use this to check processing status after syncing. -export const bundledPartnerSkillMd = ` ---- -name: scope3-agentic-partner -version: "2.0.0" -description: Scope3 Agentic Partner API - Publisher and seller integrations -api_base_url: https://api.agentic.scope3.com/api/v2/partner -auth: - type: bearer - header: Authorization - format: "Bearer {token}" - obtain_url: https://agentic.scope3.com/user-api-keys ---- +\`\`\`http +GET /api/v2/buyer/advertisers/{accountId}/audiences?take=50&skip=0 +\`\`\` -# Scope3 Agentic Partner API +**Path Parameters:** +- \`accountId\` (string, required): Advertiser ID (numeric, e.g. \`"25"\`) -This API enables partners to manage their activation partnerships and register agents with the Scope3 Agentic platform. Partners manage agents of different types (SALES, SIGNAL, CREATIVE, OUTCOME), configure authentication including OAuth, and buyers connect to agents via the Buyer API. +**Query Parameters (all optional):** +- \`take\` (number): Results per page (default: 50, max: 100) +- \`skip\` (number): Pagination offset (default: 0) -**Important**: This is a REST API accessed via the \`api_call\` tool. After reading this documentation, use \`api_call\` to make HTTP requests to the endpoints below. +**Response:** +\`\`\`json +{ + "audiences": [ + { + "audienceId": "crm-high-value", + "name": "High Value Customers", + "accountId": "25", + "consentBasis": "consent", + "status": "READY", + "deleted": false, + "uploadedCount": 1500, + "matchedCount": 1200, + "lastOperationStatus": "COMPLETED", + "createdAt": "2026-02-24T10:00:00Z", + "updatedAt": "2026-02-25T10:00:00Z" + } + ], + "total": 1, + "take": 50, + "skip": 0 +} +\`\`\` -## ⚠️ CRITICAL: Always Ask for Account Policy +--- -**NEVER default or auto-select \`accountPolicy\`.** When registering or updating an agent, you MUST ask the user which account policy they want. Do NOT assume, infer, or pick a default — always ask. +### Syndication -Explain the options to the user: -- **\`"advertiser_account"\`** — Each buyer must register their own credentials with the agent. The agent authenticates requests using per-buyer credentials. Choose this when the agent requires each buyer to have their own account (e.g., their own API key or OAuth token). -- **\`"marketplace_account"\`** — All buyers share the Scope3 marketplace credentials. The agent authenticates using a single shared credential configured at the agent level. Choose this when the agent doesn't need individual buyer accounts (e.g., a public inventory feed or a Scope3-managed integration). -- **Both \`["advertiser_account", "marketplace_account"]\`** — The agent supports either mode. Buyers who register their own account use their credentials; buyers without an account fall back to the shared marketplace credentials. Choose this for maximum flexibility. +Syndicate audiences, event sources, or catalogs to ADCP agents. Tracks status asynchronously via webhooks. -## Quick Start +#### Syndicate Resource -1. **Use \`ask_about_capability\` first**: Ask about the user's request to learn the correct workflow, endpoints, and field names -2. **Use \`api_call\` to execute**: All operations go through the generic \`api_call\` tool -3. **Base path**: All endpoints start with \`/api/v2/partner/\` -4. **Authentication**: All endpoints require authentication via the MCP session, except the platform OAuth callback (\`GET /oauth/callback\`) which is public to handle browser redirects. +\`\`\`http +POST /api/v2/buyer/advertisers/{advertiserId}/syndicate +{ + "resourceType": "AUDIENCE", + "resourceId": "aud_12345", + "adcpAgentIds": ["agent-abc-123", "agent-def-456"], + "enabled": true +} +\`\`\` ---- +**Request Body:** +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| \`resourceType\` | string | Yes | \`AUDIENCE\`, \`EVENT_SOURCE\`, or \`CATALOG\` | +| \`resourceId\` | string | Yes | ID of the resource to syndicate | +| \`adcpAgentIds\` | string[] | Yes | Array of ADCP agent ID strings (min 1) | +| \`enabled\` | boolean | Yes | Whether to enable or disable syndication | -## Account Management +**Response (201):** Returns the syndication status records for each agent. -Some account management tasks are handled in the web UI at [agentic.scope3.com](https://agentic.scope3.com). Direct users to these pages for: +#### Query Syndication Status -| Task | URL | Capabilities | -|------|-----|--------------| -| **API Keys** | [agentic.scope3.com/user-api-keys](https://agentic.scope3.com/user-api-keys) | Create, view, edit, delete, and reveal API key secrets | -| **Team Members** | [agentic.scope3.com/user-management](https://agentic.scope3.com/user-management) | Invite members, manage roles, manage seat access | -| **Billing** | Available from user menu in the UI | Manage payment methods, view invoices (via Stripe portal) | -| **Profile** | [agentic.scope3.com/user-info](https://agentic.scope3.com/user-info) | View and update user profile | +\`\`\`http +GET /api/v2/buyer/advertisers/{advertiserId}/syndication-status?resourceType=AUDIENCE&status=SYNCING&limit=20&offset=0 +\`\`\` -**Note:** Billing and member management require admin permissions. +**Query Parameters (all optional):** +| Parameter | Type | Description | +|-----------|------|-------------| +| \`resourceType\` | string | Filter by \`AUDIENCE\`, \`EVENT_SOURCE\`, or \`CATALOG\` | +| \`resourceId\` | string | Filter by specific resource ID | +| \`adcpAgentId\` | string | Filter by ADCP agent ID | +| \`enabled\` | string | Filter by \`true\` or \`false\` | +| \`status\` | string | Filter by \`PENDING\`, \`SYNCING\`, \`COMPLETED\`, \`FAILED\`, or \`DISABLED\` | +| \`limit\` | number | Max results (1-100, default 50) | +| \`offset\` | number | Pagination offset (default 0) | --- -## Available Endpoints +### Tasks -### Partner Management - -Partners represent activation partnerships. Each partner is an ACTIVATION-type seat in the platform. - -#### List Partners - -List all partners visible to the authenticated user. Supports filtering by status and name, with pagination. +Async operations (audience sync, media buy creation, etc.) return a task ID that can be polled for status. +#### Get Task Status \`\`\`http -GET /api/v2/partner/partners +GET /api/v2/buyer/tasks/{taskId} \`\`\` -**Query Parameters (all optional):** -- \`status\` (string): Filter by status — \`ACTIVE\` (default) or \`ARCHIVED\` -- \`name\` (string): Filter by name (case-insensitive, partial match) -- \`take\` (number): Number of results to return (default: 50) -- \`skip\` (number): Number of results to skip (default: 0) - **Response:** \`\`\`json { - "data": [ - { - "id": "50", - "name": "Acme Activation", - "description": "Activation partner for Acme Corporation", - "status": "ACTIVE", - "createdAt": "2026-01-15T10:00:00Z", - "updatedAt": "2026-01-15T10:00:00Z" - } - ], - "meta": { - "pagination": { - "skip": 0, - "take": 50, - "total": 1, - "hasMore": false - } + "task": { + "taskId": "550e8400-e29b-41d4-a716-446655440000", + "taskType": "audience_sync", + "status": "completed", + "resourceType": "audience", + "resourceId": "aud_12345", + "error": null, + "response": { "audience_id": "aud_12345", "member_count": 15000 }, + "metadata": {}, + "retryAfterSeconds": null, + "createdAt": "2026-01-15T10:30:00.000Z", + "updatedAt": "2026-01-15T10:35:00.000Z" } } \`\`\` +**Task types:** \`audience_sync\`, \`media_buy_create\`, \`creative_sync\` + +**Status values:** \`submitted\`, \`working\`, \`completed\`, \`failed\`, \`input-required\` + +**Error format** (AdCP-compatible, set when status is \`failed\`): +\`\`\`json +{ + "code": "VALIDATION_ERROR", + "message": "Invalid budget value", + "field": "packages[0].budget", + "suggestion": "Budget must be positive", + "recovery": "correctable" +} +\`\`\` + +**Notes:** +- Task IDs are UUIDs returned in 202 responses from async operations +- Poll this endpoint when webhooks are unavailable — use \`retryAfterSeconds\` for polling interval guidance +- \`response\` contains the original downstream response payload (varies by task type) +- Tasks are scoped to the caller's customer — you cannot access another customer's tasks + --- -#### Create Partner +### Property Lists + +Property lists define which publisher domains an advertiser targets (include lists) or avoids (exclude lists). Lists are scoped to an advertiser and automatically apply to all campaigns under that brand's targeting profile. -Create a new activation partner. This creates an ACTIVATION-type seat and adds the creator as admin. +#### Create Property List + +Create a named include or exclude list of publisher domains. Domains are resolved to internal property records. Any domains that cannot be resolved are returned as \`unresolvedDomains\`. \`\`\`http -POST /api/v2/partner/partners +POST /api/v2/buyer/advertisers/{advertiserId}/property-lists +\`\`\` + +**Request body:** +\`\`\`json { - "name": "Acme Activation", - "description": "Activation partner for Acme Corporation" + "name": "Q1 Campaign - Premium Publishers", + "purpose": "include", + "domains": ["nytimes.com", "cnn.com", "bbc.co.uk"] } \`\`\` -**Required Fields:** -- \`name\` (string, 1-255 chars): Partner display name - -**Optional Fields:** -- \`description\` (string, max 1000 chars): Partner description - **Response (201):** \`\`\`json { - "id": "50", - "name": "Acme Activation", - "description": "Activation partner for Acme Corporation", - "status": "ACTIVE", - "createdAt": "2026-01-15T10:00:00Z", - "updatedAt": "2026-01-15T10:00:00Z" + "listId": "42", + "name": "Q1 Campaign - Premium Publishers", + "purpose": "include", + "domains": ["nytimes.com", "cnn.com"], + "unresolvedDomains": ["bbc.co.uk"], + "propertyCount": 2, + "createdAt": "2026-03-16T10:00:00.000Z", + "updatedAt": "2026-03-16T10:00:00.000Z" } \`\`\` ---- +#### List Property Lists -#### Update Partner +\`\`\`http +GET /api/v2/buyer/advertisers/{advertiserId}/property-lists?purpose=include +\`\`\` -Update a partner's name or description. Requires access to the partner seat. +#### Get Property List \`\`\`http -PUT /api/v2/partner/partners/{id} -{ - "name": "Updated Partner Name", - "description": "Updated description" -} +GET /api/v2/buyer/advertisers/{advertiserId}/property-lists/{listId} \`\`\` -**Path Parameters:** -- \`id\` (string): The partner ID +#### Update Property List -**Request Body (all fields optional):** -- \`name\` (string, 1-255 chars): Updated name -- \`description\` (string, max 1000 chars): Updated description +Update name and/or replace domains entirely. ---- +\`\`\`http +PUT /api/v2/buyer/advertisers/{advertiserId}/property-lists/{listId} +\`\`\` -#### Archive Partner +**Request body:** +\`\`\`json +{ + "name": "Updated List Name", + "domains": ["nytimes.com", "washingtonpost.com"] +} +\`\`\` + +#### Delete Property List -Archive (soft-delete) a partner. Sets the partner to inactive. +Archives the property list. The list remains associated with the advertiser but is no longer active. \`\`\`http -DELETE /api/v2/partner/partners/{id} +DELETE /api/v2/buyer/advertisers/{advertiserId}/property-lists/{listId} \`\`\` -**Path Parameters:** -- \`id\` (string): The partner ID +**Recommended workflow:** +1. Create a property list with initial domains +2. Use the check endpoint (below) to validate domains against the AAO registry +3. Update the list based on check results (remove blocked domains, apply canonical corrections) +4. All campaigns under the advertiser automatically inherit the targeting +5. Property lists are automatically passed to sales agents during product discovery via ADCP \`property_list\` field -**Response:** 204 No Content +#### Resolve Property List (ADCP) ---- +Returns a property list in ADCP \`GetPropertyListResponse\` format with domain identifiers. Used by sales agents to resolve a \`PropertyListReference\` received during product discovery. Authenticated via HMAC token (not platform auth). -### Agent Management +\`\`\`http +GET /lists/{listId} +Authorization: Bearer {auth_token} +\`\`\` -Agents represent different types of integrations: -- **SALES** — Sales/media agents (ad inventory, publisher connections) -- **SIGNAL** — Signal/data agents (audience segments, first-party data) -- **CREATIVE** — Creative agents (ad creative generation, management) -- **OUTCOME** — Outcome measurement agents (attribution, conversion tracking) +**Response:** +\`\`\`json +{ + "list": { + "list_id": "123", + "name": "Premium publishers" + }, + "identifiers": [ + { "type": "domain", "value": "nytimes.com" }, + { "type": "domain", "value": "cnn.com" } + ], + "resolved_at": "2026-03-17T12:00:00.000Z", + "cache_valid_until": "2026-03-18T12:00:00.000Z" +} +\`\`\` -#### List Agents +#### Check Property List -List all agents visible to the authenticated user. Supports filtering by type, status, and relationship. +Validate a list of publisher domains against the AAO Community Registry. Identifies blocked domains (ad servers, CDNs, trackers), normalizes URLs (strips www/m prefixes), removes duplicates, and flags unknown domains. \`\`\`http -GET /api/v2/partner/agents +POST /api/v2/buyer/property-lists/check \`\`\` -**Query Parameters (all optional):** -- \`type\` (string): Filter by agent type — \`SALES\`, \`SIGNAL\`, \`CREATIVE\`, or \`OUTCOME\` -- \`status\` (string): Filter by status — \`PENDING\`, \`ACTIVE\`, or \`DISABLED\` -- \`relationship\` (string): Filter by relationship — \`SELF\` (owned by you), \`MARKETPLACE\` (all other marketplace agents) +**Request body:** +\`\`\`json +{ + "domains": ["nytimes.com", "www.cnn.com", "doubleclick.net", "unknown-site.xyz"] +} +\`\`\` **Response:** \`\`\`json { - "data": [ - { - "agentId": "premiumvideo_a1b2c3d4", - "type": "SALES", - "name": "Premium Video Exchange", - "description": "Premium CTV and video inventory provider", - "endpointUrl": "https://api.premiumvideo.com/adcp", - "protocol": "MCP", - "authenticationType": "API_KEY", - "accountPolicy": ["advertiser_account", "marketplace_account"], - "status": "ACTIVE", - "relationship": "MARKETPLACE", - "customerAccounts": [ - { - "accountIdentifier": "my-account-123", - "status": "ACTIVE" - } - ], - "requiresAccount": false, - "authConfigured": true, - "createdAt": "2026-01-15T10:00:00Z" - } - ] + "summary": { "total": 4, "remove": 1, "modify": 1, "assess": 1, "ok": 1 }, + "remove": [ + { "input": "doubleclick.net", "canonical": "doubleclick.net", "reason": "blocked", "domain_type": "ad_server" } + ], + "modify": [ + { "input": "www.cnn.com", "canonical": "cnn.com", "reason": "www prefix removed" } + ], + "assess": [ + { "domain": "unknown-site.xyz" } + ], + "ok": [ + { "domain": "nytimes.com", "source": "registry" } + ], + "reportId": "rpt_abc123" } \`\`\` -**Notes:** -- All non-DISABLED agents are visible to everyone -- PENDING agents from other owners appear as \`"status": "COMING_SOON"\` with minimal info (name only) -- \`customerAccounts\` lists the caller's own accounts (excludes marketplace accounts) -- \`requiresAccount\` is true when the agent supports per-account registration (\`advertiser_account\` or \`oauth_account\` policy) and the caller has no accounts -- \`accountPolicy\` shows the agent's allowed account types -- \`authConfigured\` indicates whether the agent has authentication credentials configured. For OAUTH agents: \`true\` means the OAuth flow is complete, \`false\` means the user still needs to authorize. Use this field (NOT \`status\`) to determine if OAuth is complete. -- \`oauth\` is present ONLY for owner's PENDING OAUTH agents where \`authConfigured\` is \`false\` (OAuth flow not yet completed) - -**Display Requirements — ALWAYS include when listing agents:** -- \`accountPolicy\`: Show what account types each agent supports (advertiser_account, marketplace_account) -- \`customerAccounts\`: Show the caller's registered accounts for each agent -- \`requiresAccount\`: Highlight agents that require account registration -- \`authConfigured\`: Show whether authentication is set up -- Group agents by status (ACTIVE first, then COMING_SOON) -- For each agent, clearly indicate: name, type, status, account policy, number of caller's accounts, and whether registration is needed +**Result buckets:** +- \`remove\`: Domains to remove — duplicates or blocked (ad servers, CDNs, trackers, intermediaries) +- \`modify\`: Domains that were normalized (e.g. \`www.example.com\` → \`example.com\`). Use the \`canonical\` value. +- \`assess\`: Unknown domains not in the registry and not blocked — may need manual review +- \`ok\`: Domains found in the registry with no issues ---- +**Limits:** 1–1000 domains per request. -#### Register Agent +#### Get Property Check Report -Register a new agent under a specific partner seat. +Retrieve a stored property check report by ID. Reports expire after 7 days. \`\`\`http -POST /api/v2/partner/agents -{ - "partnerId": "50", - "type": "SALES", - "name": "Premium Video Exchange", - "description": "Premium CTV and video inventory provider", - "endpointUrl": "https://api.premiumvideo.com/adcp", - "protocol": "MCP", - "accountPolicy": ["advertiser_account"], - "authenticationType": "API_KEY", - "auth": { - "type": "bearer", - "token": "my-api-key-for-testing" - } -} +GET /api/v2/buyer/property-lists/reports/{reportId} \`\`\` -**Required Fields:** -- \`partnerId\` (string): The partner seat ID (ACTIVATION seat) to register this agent under. Create a partner first via \`POST /partners\` if needed. -- \`type\` (string): Agent type — \`"SALES"\` (media/inventory), \`"SIGNAL"\` (data/segments), \`"CREATIVE"\` (creative generation), or \`"OUTCOME"\` (measurement/attribution). **This cannot be changed after registration.** -- \`name\` (string): Agent display name -- \`endpointUrl\` (string): URL of the agent's ADCP/MCP endpoint -- \`protocol\` (string): \`"MCP"\` or \`"A2A"\` -- \`accountPolicy\` (array of strings): **MUST ASK THE USER** — Only valid values are \`"advertiser_account"\` and \`"marketplace_account"\`. No other values exist. Do NOT default or auto-select. Always ask the user which account policy they want. -- \`authenticationType\` (string): Auth method required (\`"API_KEY"\`, \`"NO_AUTH"\`, \`"JWT"\`, \`"OAUTH"\`) -- \`auth\` (object): Initial credentials for testing and validation. Required for non-OAUTH agents. - -**Note on OAUTH agents:** When \`authenticationType\` is \`"OAUTH"\`: -- No \`redirectUri\` needed — the platform automatically uses its own callback URL -- The registration automatically initiates the OAuth flow and returns an \`authorizationUrl\` in the response -- Just present the \`authorizationUrl\` link to the user — they authorize in their browser and the platform handles the rest -- \`auth\` is not required — the OAuth flow provides the agent's credentials -- The platform automatically discovers the agent's OAuth endpoints from its ADCP endpoint URL - -**IMPORTANT: \`accountPolicy\` and \`authenticationType\` are independent.** Any combination is valid. - -**Optional Fields:** -- \`description\` (string): Agent description -- \`reportingType\` (string): \`"WEBHOOK"\`, \`"BUCKET"\`, or \`"POLLING"\` -- \`reportingPollingCadence\` (string): \`"DAILY"\` or \`"MONTHLY"\` (when reportingType is POLLING) - -**Response (non-OAUTH):** +**Response:** \`\`\`json { - "agentId": "premiumvideo_a1b2c3d4", - "type": "SALES", - "name": "Premium Video Exchange", - "status": "PENDING", - "endpointUrl": "https://api.premiumvideo.com/adcp", - "protocol": "MCP", - "accountPolicy": ["advertiser_account"], - "authenticationType": "API_KEY", - "description": "Premium CTV and video inventory provider" + "summary": { "total": 4, "remove": 1, "modify": 1, "assess": 1, "ok": 1 } } \`\`\` -**Response (OAUTH agent — returns authorization URL):** +--- + +## Error Handling + +### Hard Failures vs. No Products Found + +These are two distinct outcomes — do NOT conflate them: + +**Hard failures** — the agent returned an actual error response. Examples: +- Auth or tenant context errors +- Authentication required +- Data corruption errors +- MCP endpoint not responding + +These surface as non-2xx HTTP responses or error payloads. Treat as errors that need investigation. + +**Soft failures / no products** — the agent responded successfully (HTTP 200) but returned 0 products. This is **not an error**. It means the brief did not match available inventory. Do NOT tell the user the agent "failed." See "When discovery returns no products" above for how to handle this. + +### API Error Format + +All errors follow this format: \`\`\`json { - "agentId": "oauthseller_x1y2z3", - "type": "SALES", - "name": "OAuth Seller", - "status": "PENDING", - "endpointUrl": "https://seller.example.com/adcp", - "protocol": "MCP", - "accountPolicy": ["advertiser_account"], - "authenticationType": "OAUTH", - "oauth": { - "authorizationUrl": "https://seller.example.com/oauth/authorize?client_id=abc&...", - "agentId": "oauthseller_x1y2z3", - "agentName": "OAuth Seller" + "data": null, + "error": { + "code": "VALIDATION_ERROR", + "message": "Human-readable message", + "details": {} } } \`\`\` -**Note:** All agents start as PENDING. The owner activates when ready using \`PATCH /agents/{agentId}\` with \`{"status": "ACTIVE"}\`. For OAUTH agents, complete the OAuth flow first, then activate. +| Code | HTTP Status | Resolution | +|------|-------------|------------| +| \`VALIDATION_ERROR\` | 400 | Check request body against schema | +| \`UNAUTHORIZED\` | 401 | Verify API key/auth | +| \`ACCESS_DENIED\` | 403 | Check permissions | +| \`NOT_FOUND\` | 404 | Verify resource ID exists | +| \`CONFLICT\` | 409 | Resource already exists (e.g., brand) | +| \`RATE_LIMITED\` | 429 | Wait and retry | --- -#### Get Agent Details +## Notifications -Retrieve detailed information about a registered agent, including account data. +Notifications are events about resources you manage — campaigns going unhealthy, creatives syncing, agents registering, etc. They follow a \`resource.action\` taxonomy (e.g., \`campaign.unhealthy\`, \`creative.sync_failed\`). +### List Notifications \`\`\`http -GET /api/v2/partner/agents/{agentId} +GET /api/v2/buyer/notifications?unreadOnly=true&limit=20&offset=0 \`\`\` -**Path Parameters:** -- \`agentId\` (string): The agent ID returned from registration +**Query Parameters (all optional):** +- \`unreadOnly\` (\`true\`/\`false\`): Show only unread notifications +- \`brandAgentId\` (number): Filter by brand agent +- \`types\` (comma-separated): Filter by event types (e.g., \`campaign.unhealthy,creative.sync_failed\`) +- \`campaignId\` (string): Filter by campaign +- \`creativeId\` (string): Filter by creative +- \`limit\` (number): Results per page (default: 50, max: 100) +- \`offset\` (number): Pagination offset **Response:** \`\`\`json { - "agentId": "premiumvideo_a1b2c3d4", - "type": "SALES", - "name": "Premium Video Exchange", - "status": "ACTIVE", - "endpointUrl": "https://api.premiumvideo.com/adcp", - "protocol": "MCP", - "accountPolicy": ["advertiser_account"], - "authenticationType": "API_KEY", - "description": "Premium CTV and video inventory provider", - "customerId": 1, - "relationship": "MARKETPLACE", - "reportingType": "WEBHOOK", - "reportingPollingCadence": null, - "createdAt": "2026-01-15T10:00:00Z", - "updatedAt": "2026-01-15T10:05:00Z" + "notifications": [ + { + "id": "notif_1709123456_abc123", + "type": "campaign.unhealthy", + "data": { + "message": "Campaign \\"Q1 CTV\\" is unhealthy", + "campaignId": "camp_123", + "campaignName": "Q1 CTV" + }, + "read": false, + "acknowledged": false, + "createdAt": "2026-03-01T12:00:00Z" + } + ], + "totalCount": 15, + "unreadCount": 3, + "hasMore": false } \`\`\` ---- - -#### Update Agent +### Mark Notification as Read +\`\`\`http +POST /api/v2/buyer/notifications/{notificationId}/read +\`\`\` -Update an agent's configuration. Only the owner can update an agent. **Note: The \`type\` field cannot be changed after registration.** +Marks a single notification as seen. No request body required. +### Mark Notification as Acknowledged \`\`\`http -PATCH /api/v2/partner/agents/{agentId} -{ - "name": "Updated Agent Name", - "description": "New description", - "accountPolicy": ["advertiser_account", "marketplace_account"], - "reportingType": "POLLING", - "reportingPollingCadence": "DAILY" -} +POST /api/v2/buyer/notifications/{notificationId}/acknowledge \`\`\` -**Path Parameters:** -- \`agentId\` (string): The agent ID +Marks a notification as dealt with. Acknowledged notifications are automatically cleaned up after 90 days. No request body required. -**Request Body (all fields optional, at least one required):** -- \`name\` (string): Agent display name -- \`description\` (string): Agent description -- \`endpointUrl\` (string): Agent endpoint URL -- \`protocol\` (string): \`"MCP"\` or \`"A2A"\` -- \`accountPolicy\` (array of strings): **MUST ASK THE USER** — Only valid values are \`"advertiser_account"\` and \`"marketplace_account"\`. Do NOT default or auto-select. Always ask the user which account policy they want. -- \`authenticationType\` (string): Auth method -- \`auth\` (object): Authentication configuration -- \`reportingType\` (string): \`"WEBHOOK"\`, \`"BUCKET"\`, or \`"POLLING"\` -- \`reportingPollingCadence\` (string): \`"DAILY"\` or \`"MONTHLY"\` -- \`status\` (string): \`"PENDING"\`, \`"ACTIVE"\`, or \`"DISABLED"\` — see status transition rules below - -**Status Transition Rules:** -- **→ ACTIVE**: Only allowed if the agent has authentication configured (agent-level credentials or at least one account with auth). Returns \`VALIDATION_ERROR\` otherwise. -- **→ DISABLED**: Always allowed, no prerequisites. -- **→ PENDING**: Always allowed. +### Mark All Notifications as Read +\`\`\`http +POST /api/v2/buyer/notifications/read-all +\`\`\` -**Response:** +**Optional body:** \`\`\`json -{ - "agentId": "premiumvideo_a1b2c3d4", - "type": "SALES", - "name": "Updated Agent Name", - "status": "ACTIVE", - "updatedFields": ["name", "description", "accountPolicy", "reportingType", "reportingPollingCadence", "status"] -} +{ "brandAgentId": 123 } +\`\`\` + +If \`brandAgentId\` is provided, only marks notifications for that agent as read. Otherwise marks all unread notifications as read. + +### Proactive Notification Setup + +Unread notifications are automatically included in \`help\` and \`ask_about_capability\` tool responses. To ensure your AI agent surfaces them to users at the start of every session, add the following to your client configuration: + +- **Claude Desktop**: Create a Project and add to the project instructions: \`When using Scope3 tools, always start by calling the help tool. The response includes unread notifications — summarize those for the user before answering their question.\` +- **Claude Code**: Add the same instruction to your \`CLAUDE.md\` or project instructions. +- **API / Custom Agent**: Add it to your system prompt. +- **ChatGPT Custom GPT**: Add it to your Custom GPT's instructions. + +--- + +## Common Mistakes to Avoid + +1. **Creating campaign without advertiser** — Always create/verify advertiser first +2. **Skipping product discovery** — Always use \`POST /discovery/discover-products\` to discover products; use \`GET /discovery/{id}/discover-products\` to browse more +3. **Optimization without event source** — You need an event source (\`eventSourceId\`) before creating a campaign with event-based optimization goals +4. **Optimization without conversion data** — System needs events logged via event sources to optimize for ROAS/conversions +5. **Forgetting to execute** — Campaigns start in DRAFT status; must call \`POST /campaigns/{id}/execute\` +6. **Wrong endpoint path** — Always use \`/api/v2/buyer/\` prefix +7. **Creating advertiser without brand** — \`brand\` is required. If brand resolution returns an enriched preview, show the preview and offer to retry with \`saveBrand: true\`. Only direct the user to external registration if no brand data is found at all +8. **Auto-selecting products for the user** — When the user wants to browse/select inventory, ALWAYS present discovery results and let them choose +9. **Defaulting to a configuration without asking** — When the user says "create a campaign" without specifying how to configure it, ask them to choose (product discovery or performance metrics) +10. **Fabricating field values** — NEVER guess or make up values for required fields. Always ask the user or use values from previous API responses +11. **Making multiple API calls in one turn** — ONE discovery/mutating call per turn. Present results, END YOUR TURN, wait for the user. +12. **Missing bid price for non-fixed pricing** — If a product's pricing option has \`isFixed: false\`, \`bidPrice\` is REQUIRED in the \`POST /discovery/{id}/products\` request. Read it from the product's \`pricingOptions\` (\`rate\` or \`floorPrice\`) in the discovery response. Do NOT ask the user — the value comes from the product data. +13. **Summarizing list responses as prose** — When listing advertisers, sales agents, or campaigns, NEVER reduce the response to a sentence like "You have 13 advertisers." Always show the structured per-item details specified in the Display Requirements for that endpoint. The user needs to see each item's operational details, not a count. +14. **Using user-provided account IDs for linking** — NEVER use an account ID or account name that the user provides verbally. Account IDs for linking MUST come from the \`GET /advertisers/{id}/accounts/available?partnerId={agentId}\` discovery endpoint. If the user says "link account 06cd7033..." or "the account is named XYZ", do NOT use that value directly — call the discovery endpoint first, find the matching account in the response, and use the \`accountId\` from the API response. If the account does not appear in the discovery results, tell the user it was not found — do NOT pretend to link it. +15. **Missing credentialId with multiple credentials** — When a customer has multiple credentials for the same agent, the \`accounts/available\` endpoint requires \`credentialId\`. If omitted, the API returns an error with available credential IDs. Present those to the user and ask which to use, then retry with the chosen \`credentialId\`. + +`; + +export const bundledStorefrontSkillMd = ` +--- +name: scope3-agentic-storefront +version: "2.0.0" +description: Scope3 Agentic Storefront API - Publisher and seller integrations +api_base_url: https://api.agentic.scope3.com/api/v2/storefront +auth: + type: bearer + header: Authorization + format: "Bearer {token}" + obtain_url: https://agentic.scope3.com/user-api-keys +--- + +# Scope3 Agentic Storefront API + +This API enables partners to manage their activation partnerships, create storefronts, and register agents through the Scope3 Agentic platform. Storefronts are the single entry point for partner onboarding — agents are created as part of inventory source setup. + +**Important**: This is a REST API accessed via the \`api_call\` tool. After reading this documentation, use \`api_call\` to make HTTP requests to the endpoints below. + +## ⚠️ CRITICAL: Never Render Your Own UI When a Tool Is Already Returning One + +**When a tool response already includes a UI component, display it as-is. Do NOT generate your own HTML artifacts, dashboards, charts, or visual components on top of it.** If the tool returns a UI, that is the UI — do not create a competing or duplicate visualization. + +## Notifications + +The \`help\` and \`ask_about_capability\` tools include unread notifications in their responses. When a response contains a "Unread Notifications" section, summarize those notifications for the user before answering their question. + +## Getting Started + +All operations go through the generic \`api_call\` tool. Base path: \`/api/v2/storefront/\`. All endpoints require authentication via the MCP session, except the platform OAuth callback (\`GET /oauth/callback\`) which is public. + +### Onboarding Flow + +\`\`\` +1. Create Storefront → POST /storefront { platformId, name, publisherDomain } +2. Add Inventory Source → POST /storefront/inventory-sources +3. Activate Source → PUT /storefront/inventory-sources/{sourceId} { status: "active" } +4. Set Up Billing → POST /storefront/billing/connect +5. Check Readiness → GET /storefront/readiness +6. Enable Storefront → PUT /storefront { enabled: true } \`\`\` --- +## Account Management + +Some account management tasks are handled in the web UI at [agentic.scope3.com](https://agentic.scope3.com). Direct users to these pages for: + +| Task | URL | Capabilities | +|------|-----|--------------| +| **API Keys** | [agentic.scope3.com/user-api-keys](https://agentic.scope3.com/user-api-keys) | Create, view, edit, delete, and reveal API key secrets | +| **Team Members** | [agentic.scope3.com/user-management](https://agentic.scope3.com/user-management) | Invite members, manage roles, manage partner access | +| **Billing** | Available from user menu in the UI | Manage payment methods, view invoices (via Stripe portal) | +| **Profile** | [agentic.scope3.com/user-info](https://agentic.scope3.com/user-info) | View and update user profile | + +**Note:** Billing and member management require admin permissions. + +--- + +## Available Endpoints -### Account Registration (Buyer API) +### Storefront Management -**Account registration has moved to the Buyer API** (\`/api/v2/buyer/sales-agents\`). Buyers must specify which advertiser (BUYER seat) they are connecting for. +Each customer can have at most one storefront, keyed by a platformId slug. + +#### Create Storefront \`\`\`http -POST /api/v2/buyer/sales-agents/{agentId}/accounts +POST /api/v2/storefront { - "advertiserId": "300", - "accountIdentifier": "my-publisher-account", - "auth": { - "type": "bearer", - "token": "my-api-key" - } + "platformId": "cvs-media", + "name": "CVS Media", + "publisherDomain": "cvs.com" } \`\`\` **Required Fields:** -- \`advertiserId\` (string): The advertiser seat ID (BUYER seat) to connect this account for -- \`accountIdentifier\` (string): Unique account identifier +- \`platformId\` (string): Lowercase slug with hyphens +- \`name\` (string, 1-255 chars): Display name **Optional Fields:** -- \`auth\` (object): Authentication credentials. Required for API_KEY/JWT agents, not needed for OAUTH agents. +- \`publisherDomain\` (string): Publisher's domain +- \`plan\` (string): Plan tier, currently only \`"basic"\` (default) ---- - -### OAuth Endpoints +#### Get Customer Storefront +\`\`\`http +GET /api/v2/storefront +\`\`\` -#### Start Setup OAuth Flow +#### Update Storefront +\`\`\`http +PUT /api/v2/storefront +{ + "name": "CVS Media Network", + "enabled": true +} +\`\`\` -Initiate the OAuth flow for **agent-level setup**. Tokens are stored in the agent's configuration. The \`redirectUri\` is optional — if omitted, the platform-hosted callback URL is used automatically. +**Note:** Setting \`enabled: true\` requires all readiness checks to pass. Check readiness first with \`GET /storefront/readiness\`. +#### Delete Storefront \`\`\`http -POST /api/v2/partner/agents/{agentId}/oauth/authorize -{} +DELETE /api/v2/storefront \`\`\` -#### Start Account OAuth Flow +--- -Initiate the OAuth flow for **per-account registration**. Tokens are stored per-account. The \`redirectUri\` is optional — if omitted, the platform-hosted callback URL is used automatically. +### Inventory Sources (Agent Registration) + +When \`executionType\` is \`"agent"\`, creating an inventory source also registers the agent. + +#### Create Inventory Source \`\`\`http -POST /api/v2/partner/agents/{agentId}/accounts/oauth/authorize -{} +POST /api/v2/storefront/inventory-sources +{ + "sourceId": "snap-ads-agent", + "name": "Snap Ads Agent", + "executionType": "agent", + "type": "SALES", + "endpointUrl": "https://agent.example.com/adcp", + "protocol": "MCP", + "authenticationType": "API_KEY", + "auth": { "type": "bearer", "token": "my-api-key" } +} \`\`\` -#### Platform OAuth Callback (Public - No Auth) +**Required Fields:** +- \`sourceId\` (string): Unique ID within the storefront +- \`name\` (string, 1-255 chars): Display name -Platform-hosted callback that automatically completes the OAuth flow. AI agents should use this URL as their \`redirectUri\`. +**Required when executionType is "agent":** +- \`type\`: \`"SALES"\`, \`"SIGNAL"\`, \`"CREATIVE"\`, or \`"OUTCOME"\` +- \`endpointUrl\`: Agent endpoint URL (public HTTPS) +- \`protocol\`: \`"MCP"\` or \`"A2A"\` +- \`authenticationType\`: \`"API_KEY"\`, \`"NO_AUTH"\`, \`"JWT"\`, or \`"OAUTH"\` +- \`auth\` (object): Initial credentials. Required for non-OAUTH agents. -\`\`\` -GET /api/v2/partner/oauth/callback?code={code}&state={state} +#### List Inventory Sources +\`\`\`http +GET /api/v2/storefront/inventory-sources \`\`\` -- **Production:** \`https://api.agentic.scope3.com/api/v2/partner/oauth/callback\` -- **Staging:** \`https://api.agentic.staging.scope3.com/api/v2/partner/oauth/callback\` +#### Get Inventory Source +\`\`\`http +GET /api/v2/storefront/inventory-sources/{sourceId} +\`\`\` -#### Exchange OAuth Code +#### Update Inventory Source +\`\`\`http +PUT /api/v2/storefront/inventory-sources/{sourceId} +{ "status": "active" } +\`\`\` -Exchange an OAuth authorization code for tokens manually. Only needed if you have your own callback server. +**Status Transitions:** +- \`pending → active\`: Activates source and linked agent +- \`pending → disabled\`: Disables source and linked agent +- \`active → disabled\`: Disables source and linked agent +#### Delete Inventory Source \`\`\`http -POST /api/v2/partner/agents/{agentId}/oauth/callback -{ - "code": "authorization-code-from-redirect", - "state": "state-parameter-from-redirect" -} +DELETE /api/v2/storefront/inventory-sources/{sourceId} \`\`\` --- -## Key Concepts - -### Partners and Agents +### Storefront Readiness -- **Partners** are ACTIVATION-type seats that represent activation partnerships -- **Agents** are registered under a specific partner via \`partnerId\` and have a \`type\` (SALES, SIGNAL, CREATIVE, or OUTCOME) -- Create a partner first (\`POST /partners\`), then register agents under it (\`POST /agents\` with \`partnerId\` and \`type\`) -- Buyers connect to SALES agents via the Buyer API (\`POST /api/v2/buyer/sales-agents/{agentId}/accounts\`) -- The agent \`type\` cannot be changed after registration +\`\`\`http +GET /api/v2/storefront/readiness +\`\`\` -### Account Policies +**Status values:** \`ready\` (all checks pass), \`blocked\` (blockers present) -The \`accountPolicy\` field is an array specifying how buyers authenticate with the agent. **Always ask the user which policy they want — never default.** +**Checks:** \`inventory_sources\`, \`agent_status\`, \`agent_auth\`, \`billing_setup\` -| Value | What it means | Buyer experience | When to use | -|-------|---------------|------------------|-------------| -| \`["advertiser_account"]\` | Each buyer registers their own credentials with the agent | Buyers must set up an account before using the agent | Agent requires per-buyer API keys, OAuth tokens, or account IDs | -| \`["marketplace_account"]\` | All buyers share a single Scope3 marketplace credential configured at the agent level | No setup needed — buyers can use the agent immediately | Agent uses a shared feed, public API, or Scope3-managed integration | -| \`["advertiser_account", "marketplace_account"]\` | Both modes supported — buyers with their own account use it, others fall back to marketplace credentials | Optional account setup; works either way | Maximum flexibility — lets buyers choose their level of integration | +--- -**How credential resolution works when both are set:** The platform checks for a buyer's own advertiser account first. If none exists, it falls back to the marketplace account. If no marketplace account exists, it falls back to the agent-level configuration. +### Storefront Billing (Stripe Connect) -### Authentication Types +#### Provision Stripe Connect Account +\`\`\`http +POST /api/v2/storefront/billing/connect +\`\`\` -| Type | Auth Object Format | Notes | -|------|-------------------|-------| -| \`API_KEY\` | \`{ "type": "bearer", "token": "..." }\` | Most common. Simple token-based auth. | -| \`NO_AUTH\` | \`{}\` | Agent endpoint is open. | -| \`JWT\` | \`{ "type": "jwt", "privateKey": "...", ... }\` | JSON Web Token with private key signing. | -| \`OAUTH\` | Managed by OAuth flow | Credentials obtained through OAuth redirect flow. | +#### Get Billing Config +\`\`\`http +GET /api/v2/storefront/billing +\`\`\` -### Agent Lifecycle +#### Get Stripe Account Status +\`\`\`http +GET /api/v2/storefront/billing/status +\`\`\` +#### Get Balance Transactions +\`\`\`http +GET /api/v2/storefront/billing/transactions?limit=25&starting_after=txn_xxx \`\`\` -Registration (POST /agents with partnerId) - --> PENDING (initial state, always) - For OAUTH agents: complete the OAuth flow first - Owner decides when to activate: - --> PATCH /agents/{agentId} with status: "ACTIVE" - --> ACTIVE (requires auth to be configured) -Any state --> DISABLED (via PATCH, always allowed) -DISABLED --> ACTIVE (via PATCH, requires auth configured) +#### Get Payouts +\`\`\`http +GET /api/v2/storefront/billing/payouts?limit=25&starting_after=po_xxx \`\`\` -**Key points:** -- All agents start as PENDING — registration never auto-activates -- The owner explicitly activates when ready via \`PATCH /agents/{agentId}\` with \`{"status": "ACTIVE"}\` -- Non-owners see PENDING agents as \`COMING_SOON\` in list responses +#### Get Onboarding URL +\`\`\`http +GET /api/v2/storefront/billing/onboard +\`\`\` --- -## Error Handling +### Agent Discovery (Read-Only) -All errors follow this format: -\`\`\`json -{ - "data": null, - "error": { - "code": "ERROR_CODE", - "message": "Human-readable message", - "details": {} - } -} +#### List Agents +\`\`\`http +GET /api/v2/storefront/agents \`\`\` -| Code | HTTP Status | Resolution | -|------|-------------|------------| -| \`VALIDATION_ERROR\` | 400 | Check request body against schema | -| \`UNAUTHORIZED\` | 401 | Verify API key/auth | -| \`ACCESS_DENIED\` | 403 | Check permissions | -| \`NOT_FOUND\` | 404 | Verify agent/partner ID exists | -| \`CONFLICT\` | 409 | Agent already registered at endpoint URL or duplicate account | -| \`RATE_LIMITED\` | 429 | Wait and retry | +**Query Parameters:** \`type\`, \`status\`, \`relationship\` + +#### Get Agent Details +\`\`\`http +GET /api/v2/storefront/agents/{agentId} +\`\`\` --- -## Registering an OAuth Agent +### OAuth Endpoints -**CRITICAL: NEVER ask the user for OAuth credentials.** No client_id, client_secret, authorization URL, token URL, or scopes. The platform discovers and handles ALL of this automatically. +#### Start Setup OAuth Flow +\`\`\`http +POST /api/v2/storefront/agents/{agentId}/oauth/authorize +{} +\`\`\` -**Steps:** -1. Ask the user for: \`partnerId\`, \`name\`, \`endpointUrl\`, \`protocol\`, and \`accountPolicy\` -2. Set \`authenticationType\` to \`"OAUTH"\` — do NOT include an \`auth\` field -3. Call \`POST /api/v2/partner/agents\` with those fields -4. The response includes an \`oauth.authorizationUrl\` — present this link to the user -5. The user clicks the link and authorizes in their browser -6. The platform handles the rest (token exchange, storage) +#### Start Account OAuth Flow +\`\`\`http +POST /api/v2/storefront/agents/{agentId}/accounts/oauth/authorize +{} +\`\`\` -**Example request:** -\`\`\`json +#### Platform OAuth Callback (Public - No Auth) +\`\`\` +GET /api/v2/storefront/oauth/callback?code={code}&state={state} +\`\`\` + +#### Exchange OAuth Code +\`\`\`http +POST /api/v2/storefront/agents/{agentId}/oauth/callback { - "partnerId": "28", - "type": "SALES", - "name": "Snap Sales Agent", - "endpointUrl": "https://snapadcp.scope3.com/mcp", - "protocol": "MCP", - "accountPolicy": ["advertiser_account"], - "authenticationType": "OAUTH" + "code": "authorization-code-from-redirect", + "state": "state-parameter-from-redirect" } \`\`\` -**What happens behind the scenes (you do NOT need to do any of this):** -- Platform discovers OAuth endpoints from the agent's \`.well-known/oauth-authorization-server\` -- Platform performs dynamic client registration (RFC 7591) if needed -- Platform generates PKCE challenge and stores pending flow -- After user authorizes, platform exchanges the code for tokens automatically +--- + +### Notifications + +#### List Notifications +\`\`\`http +GET /api/v2/storefront/notifications?unreadOnly=true&limit=20&offset=0 +\`\`\` + +#### Mark Notification as Read +\`\`\`http +POST /api/v2/storefront/notifications/{notificationId}/read +\`\`\` + +#### Mark Notification as Acknowledged +\`\`\`http +POST /api/v2/storefront/notifications/{notificationId}/acknowledge +\`\`\` + +#### Mark All Notifications as Read +\`\`\`http +POST /api/v2/storefront/notifications/read-all +\`\`\` + +--- + +## Key Concepts + +### Authentication Types + +| Type | Auth Object Format | Notes | +|------|-------------------|-------| +| \`API_KEY\` | \`{ "type": "bearer", "token": "..." }\` | Most common | +| \`NO_AUTH\` | \`{}\` | Agent endpoint is open | +| \`JWT\` | \`{ "type": "jwt", "privateKey": "..." }\` | JSON Web Token | +| \`OAUTH\` | Managed by OAuth flow | Credentials obtained through OAuth redirect | --- ## Common Mistakes to Avoid -1. **Asking the user for OAuth credentials** - NEVER ask for client_id, client_secret, authorization URL, token URL, or scopes for OAUTH agents. The platform discovers everything automatically. Just set \`authenticationType: "OAUTH"\` with no \`auth\` field. -2. **Missing \`partnerId\` when registering an agent** - Every agent must be registered under a partner. Create a partner first via \`POST /partners\`. -3. **Auto-selecting \`accountPolicy\`** - \`accountPolicy\` is a required field that MUST be chosen by the user. Never default it or pick a value automatically. Always ask the user which account policy they want before registering an agent. -4. **Registering accounts on the Partner API** - Account registration is on the Buyer API (\`POST /api/v2/buyer/sales-agents/{agentId}/accounts\`), not the Partner API. -5. **Missing \`advertiserId\` when registering an account** - Buyers must specify which advertiser seat to connect for. -6. **Missing \`auth\` for non-OAUTH agents** - Initial credentials are required for testing and validation when \`authenticationType\` is NOT \`OAUTH\` -7. **No \`redirectUri\` needed for OAuth registration** - The platform automatically uses its own callback URL -8. **Using GET for OAuth authorize** - The OAuth authorize endpoints are POST, not GET -9. **Calling endpoints without auth** - All endpoints require authentication except \`GET /oauth/callback\` -10. **Wrong base path** - Partner API endpoints use \`/api/v2/partner/\` prefix -11. **Registering accounts on PENDING agents** - Agent must be ACTIVE before accounts can be registered +1. **Asking the user for OAuth credentials** - The platform discovers everything automatically +2. **Auto-selecting accountPolicy** - Always ask the user +3. **Registering credentials/accounts on the Storefront API** - Use the Buyer API +4. **Missing advertiserId when linking an account** - Buyers must specify which advertiser +5. **Missing auth for non-OAUTH agents** - Initial credentials required +6. **No redirectUri needed for OAuth** - Platform uses its own callback URL +7. **Using GET for OAuth authorize** - The endpoints are POST +8. **Calling endpoints without auth** - All require auth except OAuth callback +9. **Wrong base path** - Storefront API uses \`/api/v2/storefront/\` prefix +10. **Registering accounts on PENDING agents** - Agent must be ACTIVE first +11. **Enabling storefront without checking readiness** - Always check readiness first `; -export const bundledAt = '2026-02-17T18:48:16.000Z'; +export const bundledAt = '2026-03-31T00:00:00.000Z'; /** * Get bundled skill.md for a persona @@ -1940,8 +3034,8 @@ export function getBundledSkillMd(persona: Persona = 'buyer'): string { switch (persona) { case 'buyer': return bundledBuyerSkillMd; - case 'partner': - return bundledPartnerSkillMd; + case 'storefront': + return bundledStorefrontSkillMd; default: return bundledBuyerSkillMd; } diff --git a/src/skill/index.ts b/src/skill/index.ts index df5a288..b02a040 100644 --- a/src/skill/index.ts +++ b/src/skill/index.ts @@ -5,4 +5,4 @@ export * from './types'; export { fetchSkillMd, getBundledSkillMd } from './fetcher'; export { parseSkillMd } from './parser'; -export { bundledBuyerSkillMd, bundledPartnerSkillMd } from './bundled'; +export { bundledBuyerSkillMd, bundledStorefrontSkillMd } from './bundled'; diff --git a/src/types/index.ts b/src/types/index.ts index 412309f..59ea17d 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -10,7 +10,7 @@ export type ApiVersion = 'v1' | 'v2' | 'latest'; /** API persona - determines which API surface to use */ -export type Persona = 'buyer' | 'partner'; +export type Persona = 'buyer' | 'storefront'; /** Environment for API endpoints */ export type Environment = 'production' | 'staging'; @@ -662,35 +662,163 @@ export interface DiscoverSignalsInput { } // ============================================================================ -// Partner Types (Partner Persona) +// Storefront Types (Storefront Persona) // ============================================================================ -export interface Partner { - id: string; +export interface Storefront { + platformId: string; name: string; - description?: string; - status: string; + publisherDomain?: string; + plan?: string; + enabled: boolean; createdAt: string; updatedAt: string; } -export interface CreatePartnerInput { +export interface CreateStorefrontInput { + platformId: string; name: string; - description?: string; + publisherDomain?: string; + plan?: string; } -export interface UpdatePartnerInput { +export interface UpdateStorefrontInput { name?: string; + publisherDomain?: string; + platformId?: string; + plan?: string; + enabled?: boolean; +} + +// ============================================================================ +// Inventory Source Types (Storefront Persona) +// ============================================================================ + +export type InventorySourceExecutionType = 'agent'; + +export interface InventorySource { + sourceId: string; + name: string; + executionType?: InventorySourceExecutionType; + status: string; + agentId?: string; + type?: AgentType; + endpointUrl?: string; + protocol?: AgentProtocol; + authenticationType?: AgentAuthenticationType; + authConfigured?: boolean; + executionConfig?: Record; description?: string; + oauth?: { + authorizationUrl: string; + agentId: string; + agentName: string; + }; + createdAt: string; + updatedAt: string; } -export interface ListPartnersParams extends PaginationParams { - status?: string; +export interface CreateInventorySourceInput { + sourceId: string; + name: string; + executionType?: InventorySourceExecutionType; + type?: AgentType; + endpointUrl?: string; + protocol?: AgentProtocol; + authenticationType?: AgentAuthenticationType; + auth?: { + type: string; + token?: string; + privateKey?: string; + }; + executionConfig?: Record; + description?: string; +} + +export interface UpdateInventorySourceInput { name?: string; + executionType?: InventorySourceExecutionType; + executionConfig?: Record; + status?: string; } // ============================================================================ -// Agent Types (Partner Persona) +// Storefront Readiness Types +// ============================================================================ + +export type ReadinessStatus = 'ready' | 'blocked'; + +export interface ReadinessCheck { + id: string; + name: string; + description: string; + category: string; + status: string; + isBlocker: boolean; + details?: string; +} + +export interface StorefrontReadiness { + platformId: string; + status: ReadinessStatus; + checks: ReadinessCheck[]; +} + +// ============================================================================ +// Storefront Billing Types (Stripe Connect) +// ============================================================================ + +export interface BillingFee { + name: string; + feePercent: number; +} + +export interface StorefrontBilling { + stripeConnectedAccountId: string; + onboardingStatus: string; + platformFeePercent?: number; + fees?: BillingFee[]; + currency?: string; + defaultNetDays?: number; + createdAt: string; + updatedAt: string; +} + +export interface StorefrontBillingConfig { + platformId: string; + billing: StorefrontBilling | null; +} + +export interface StripeConnectResponse { + platformId: string; + stripeConnectedAccountId: string; + onboardingUrl: string; +} + +// ============================================================================ +// Notification Types (Storefront Persona) +// ============================================================================ + +export interface Notification { + id: string; + type: string; + message: string; + read: boolean; + acknowledged: boolean; + brandAgentId?: number; + createdAt: string; +} + +export interface ListNotificationsParams { + unreadOnly?: boolean; + brandAgentId?: number; + types?: string; + limit?: number; + offset?: number; +} + +// ============================================================================ +// Agent Types (Storefront Persona) // ============================================================================ export type AgentType = 'SALES' | 'SIGNAL' | 'CREATIVE' | 'OUTCOME'; @@ -724,24 +852,6 @@ export interface Agent { updatedAt?: string; } -export interface RegisterAgentInput { - partnerId: string; - type: AgentType; - name: string; - endpointUrl: string; - protocol: AgentProtocol; - accountPolicy: string[]; - authenticationType: AgentAuthenticationType; - auth?: { - type: string; - token?: string; - privateKey?: string; - }; - description?: string; - reportingType?: string; - reportingPollingCadence?: string; -} - export interface UpdateAgentInput { name?: string; description?: string; From 2fb685584094164ef738bc5092b99234bbde520b Mon Sep 17 00:00:00 2001 From: Emma Mulitz Date: Tue, 31 Mar 2026 09:55:42 -0400 Subject: [PATCH 5/6] chore: update docs, examples, and test scripts for storefront/buyer v2 API Add unit tests for all 8 new buyer resources (event sources, measurement data, catalogs, audiences, syndication, tasks, property lists, creatives). Rename partner workflow scripts to storefront. Update buyer guide with new resource documentation. Fix all remaining partner references. --- docs/TESTING.md | 13 +- docs/buyer-guide.md | 90 +++++++++++- package.json | 4 +- scripts/README.md | 15 +- scripts/test-partner-workflow.sh | 108 -------------- scripts/test-sdk-workflow.ts | 32 ++-- scripts/test-storefront-workflow.sh | 108 ++++++++++++++ src/__tests__/resources/audiences.test.ts | 53 +++++++ src/__tests__/resources/catalogs.test.ts | 66 +++++++++ src/__tests__/resources/creatives.test.ts | 115 +++++++++++++++ src/__tests__/resources/event-sources.test.ts | 94 ++++++++++++ .../resources/measurement-data.test.ts | 38 +++++ .../resources/property-lists.test.ts | 137 ++++++++++++++++++ src/__tests__/resources/syndication.test.ts | 82 +++++++++++ src/__tests__/resources/tasks.test.ts | 33 +++++ src/types/index.ts | 2 +- 16 files changed, 845 insertions(+), 145 deletions(-) delete mode 100755 scripts/test-partner-workflow.sh create mode 100755 scripts/test-storefront-workflow.sh create mode 100644 src/__tests__/resources/audiences.test.ts create mode 100644 src/__tests__/resources/catalogs.test.ts create mode 100644 src/__tests__/resources/creatives.test.ts create mode 100644 src/__tests__/resources/event-sources.test.ts create mode 100644 src/__tests__/resources/measurement-data.test.ts create mode 100644 src/__tests__/resources/property-lists.test.ts create mode 100644 src/__tests__/resources/syndication.test.ts create mode 100644 src/__tests__/resources/tasks.test.ts diff --git a/docs/TESTING.md b/docs/TESTING.md index 419d3ec..1aacc56 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -8,7 +8,7 @@ Run the test suite: npm test ``` -This runs 211 unit tests covering: +This runs 384+ unit tests covering: - Client initialization - REST and MCP adapters - All resource classes @@ -28,8 +28,8 @@ export SCOPE3_API_KEY=your_api_key ./dist/cli/index.js campaigns list ./dist/cli/index.js bundles create --advertiser-id --channels display -# Test partner persona -./dist/cli/index.js --persona partner partners list +# Test storefront persona +./dist/cli/index.js --persona storefront storefront get # Test config ./dist/cli/index.js config set apiKey your_key @@ -48,7 +48,7 @@ export SCOPE3_API_KEY=your_api_key # CLI workflow tests npm run test:buyer # Buyer persona: advertisers, bundles, campaigns -npm run test:partner # Partner persona: health check +npm run test:storefront # Storefront persona: health check # TypeScript SDK test npm run test:sdk @@ -75,9 +75,10 @@ export SCOPE3_ENVIRONMENT=staging - Bundle creation and product discovery - Campaign creation and lifecycle -### Partner Workflow (`test-partner-workflow.sh`) -- Partner listing +### Storefront Workflow (`test-storefront-workflow.sh`) +- Storefront get - Agent listing +- Inventory source listing - Config management - Skill.md fetching diff --git a/docs/buyer-guide.md b/docs/buyer-guide.md index 3a3b1a7..08584dd 100644 --- a/docs/buyer-guide.md +++ b/docs/buyer-guide.md @@ -4,11 +4,13 @@ The buyer persona enables AI-powered programmatic advertising with: -- Advertiser management +- Advertiser management with rich sub-resources (conversion events, creative sets, test cohorts, event sources, measurement data, catalogs, audiences, syndication, property lists) - Bundle-based inventory discovery -- 3 campaign types (discovery, performance, audience) +- 3 campaign types (discovery, performance, audience) with creative management - Reporting and analytics -- Reporting and sales agents +- Sales agents +- Async task tracking +- Property list checks ## Setup @@ -130,6 +132,19 @@ Signal-based audience targeting (coming soon). await client.campaigns.createAudience({ ... }); ``` +## Campaign Sub-Resources + +### Creatives + +```typescript +const creatives = client.campaigns.creatives(campaignId); +await creatives.list(); +await creatives.list({ quality: 'high', take: 5 }); +await creatives.get('creative-123'); +await creatives.update('creative-123', { /* updates */ }); +await creatives.delete('creative-123'); +``` + ## Advertiser Sub-Resources Access sub-resources scoped to an advertiser. @@ -158,6 +173,67 @@ await cohorts.list(); await cohorts.create({ name: 'A/B Test', splitPercentage: 50 }); ``` +### Event Sources + +```typescript +const eventSources = client.advertisers.eventSources(advId); +await eventSources.sync({ /* event source config */ }); +await eventSources.list(); +await eventSources.create({ /* event source data */ }); +await eventSources.get('es-123'); +await eventSources.update('es-123', { /* updates */ }); +await eventSources.delete('es-123'); +``` + +### Measurement Data + +```typescript +const measurementData = client.advertisers.measurementData(advId); +await measurementData.sync({ /* measurement data config */ }); +``` + +### Catalogs + +```typescript +const catalogs = client.advertisers.catalogs(advId); +await catalogs.sync({ /* catalog data */ }); +await catalogs.list(); +await catalogs.list({ type: 'product', take: 10 }); +``` + +### Audiences + +```typescript +const audiences = client.advertisers.audiences(advId); +await audiences.sync({ /* audience data */ }); +await audiences.list(); +``` + +### Syndication + +```typescript +const syndication = client.advertisers.syndication(advId); +await syndication.syndicate({ /* syndication config */ }); +await syndication.status(); +await syndication.status({ resourceType: 'campaign' }); +``` + +### Property Lists + +```typescript +const propertyLists = client.advertisers.propertyLists(advId); +await propertyLists.create({ /* property list data */ }); +await propertyLists.list(); +await propertyLists.list({ purpose: 'inclusion' }); +await propertyLists.get('pl-123'); +await propertyLists.update('pl-123', { /* updates */ }); +await propertyLists.delete('pl-123'); + +// Top-level property list checks (not advertiser-scoped) +await client.propertyListChecks.check({ domains: ['example.com'] }); +await client.propertyListChecks.getReport('report-123'); +``` + ## Signals ```typescript @@ -188,6 +264,14 @@ await client.salesAgents.registerAccount('agent-123', { }); ``` +## Tasks + +Check the status of async tasks. + +```typescript +const task = await client.tasks.get('task-123'); +``` + ## Pagination All list methods support pagination: diff --git a/package.json b/package.json index 251455d..d9804ec 100644 --- a/package.json +++ b/package.json @@ -27,9 +27,9 @@ "format": "prettier --write \"src/**/*.ts\"", "type-check": "tsc --noEmit", "test:buyer": "./scripts/test-buyer-workflow.sh", - "test:partner": "./scripts/test-partner-workflow.sh", + "test:storefront": "./scripts/test-storefront-workflow.sh", "test:sdk": "ts-node scripts/test-sdk-workflow.ts", - "test:all": "npm run test:buyer && npm run test:partner", + "test:all": "npm run test:buyer && npm run test:storefront", "generate-schemas": "npx tsx scripts/generate-schemas.ts", "detect-drift": "npx tsx scripts/detect-drift.ts", "prepare": "husky", diff --git a/scripts/README.md b/scripts/README.md index 2936fb7..a292b51 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -14,17 +14,15 @@ export SCOPE3_API_KEY=your_api_key # get from agentic.scope3.com -> Manage A | Script | Persona | What it tests | |--------|---------|---------------| | `test-buyer-workflow.sh` | buyer | Advertisers, bundles, product discovery, campaigns | -| `test-brand-workflow.sh` | brand | Brand CRUD with manifest URL and inline JSON | -| `test-partner-workflow.sh` | partner | Health check, config, skill.md | -| `test-sdk-workflow.ts` | all 3 | Full TypeScript SDK test (not CLI) | +| `test-storefront-workflow.sh` | storefront | Health check, config, skill.md | +| `test-sdk-workflow.ts` | both | Full TypeScript SDK test (not CLI) | ## Usage ```bash # CLI workflow tests (bash) ./scripts/test-buyer-workflow.sh -./scripts/test-brand-workflow.sh -./scripts/test-partner-workflow.sh +./scripts/test-storefront-workflow.sh # Use staging ./scripts/test-buyer-workflow.sh --staging @@ -35,12 +33,11 @@ npx ts-node scripts/test-sdk-workflow.ts --staging # Or via npm scripts npm run test:buyer -npm run test:brand -npm run test:partner +npm run test:storefront npm run test:sdk -npm run test:all # runs all 4 +npm run test:all # runs both ``` ## Cleanup -The buyer and brand scripts auto-clean test resources on exit. If a script is interrupted, check your dashboard for leftover test advertisers/brands. +The buyer script auto-cleans test resources on exit. If a script is interrupted, check your dashboard for leftover test advertisers. diff --git a/scripts/test-partner-workflow.sh b/scripts/test-partner-workflow.sh deleted file mode 100755 index 8d1c6cf..0000000 --- a/scripts/test-partner-workflow.sh +++ /dev/null @@ -1,108 +0,0 @@ -#!/bin/bash - -# Scope3 SDK v2 - Partner Workflow Test -# Tests the partner persona flow: partners, agents, config, skill.md -# -# Usage: -# export SCOPE3_API_KEY=your_partner_api_key -# ./scripts/test-partner-workflow.sh -# ./scripts/test-partner-workflow.sh --staging - -set -euo pipefail - -# Colors -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' - -CLI="node dist/cli/index.js --persona partner --format json" -STEP=0 - -if [[ "${1:-}" == "--staging" ]]; then - CLI="$CLI --environment staging" - echo -e "${YELLOW}Using STAGING environment${NC}" -fi - -step() { STEP=$((STEP + 1)); echo -e "\n${BLUE}[$STEP]${NC} $1"; } -pass() { echo -e " ${GREEN}PASS${NC} $1"; } -fail() { echo -e " ${RED}FAIL${NC} $1"; } -warn() { echo -e " ${YELLOW}SKIP${NC} $1"; } - -if [ -z "${SCOPE3_API_KEY:-}" ]; then - echo -e "${RED}Error: SCOPE3_API_KEY not set${NC}" - echo " export SCOPE3_API_KEY=your_api_key" - exit 1 -fi - -echo "" -echo "==========================================" -echo " PARTNER WORKFLOW TEST (SDK v2)" -echo "==========================================" - -# ── 1. CLI version ────────────────────────────────────────────────── -step "CLI version check" -RESULT=$(node dist/cli/index.js --cli-version 2>&1) && { - pass "CLI version: $RESULT" -} || { - fail "Could not get CLI version" -} - -# ── 2. Config commands ────────────────────────────────────────────── -step "Config set/get" -node dist/cli/index.js config set persona partner > /dev/null 2>&1 && { - pass "Config set persona=partner" -} || { - warn "Config set failed" -} - -RESULT=$(node dist/cli/index.js config get persona 2>&1) && { - pass "Config get persona: $RESULT" -} || { - warn "Config get failed" -} - -# ── 3. List partners ──────────────────────────────────────────────── -step "List partners" -RESULT=$($CLI partners list 2>&1) && { - pass "Listed partners" -} || { - warn "Could not list partners" -} - -# ── 4. List agents ────────────────────────────────────────────────── -step "List agents" -RESULT=$($CLI agents list 2>&1) && { - pass "Listed agents" -} || { - warn "Could not list agents" -} - -# ── 5. Fetch skill.md ────────────────────────────────────────────── -step "Fetch partner skill.md from live API" -RESULT=$(node -e " -const { fetchSkillMd } = require('./dist/skill'); -fetchSkillMd({ persona: 'partner' }).then(s => { - console.log('Lines: ' + s.split('\n').length); - console.log('OK'); -}).catch(e => { console.error(e.message); process.exit(1); }); -" 2>&1) && { - pass "Fetched partner skill.md ($RESULT)" -} || { - warn "Could not fetch skill.md (using bundled fallback)" -} - -# ── Summary ───────────────────────────────────────────────────────── -echo "" -echo "==========================================" -echo " PARTNER WORKFLOW SUMMARY" -echo "==========================================" -echo "" -echo " The partner persona supports:" -echo " - Partner management (list, create, update, archive)" -echo " - Agent management (list, get, register, update)" -echo " - Config management" -echo " - skill.md fetching" -echo "" -echo -e "${GREEN}Partner workflow test complete.${NC}" diff --git a/scripts/test-sdk-workflow.ts b/scripts/test-sdk-workflow.ts index 27178eb..401f1fa 100644 --- a/scripts/test-sdk-workflow.ts +++ b/scripts/test-sdk-workflow.ts @@ -170,24 +170,24 @@ async function testBuyer() { } } -async function testPartner() { +async function testStorefront() { console.log('\n=========================================='); - console.log(' PARTNER PERSONA'); + console.log(' STOREFRONT PERSONA'); console.log('=========================================='); - const client = makeClient('partner'); + const client = makeClient('storefront'); - // List partners - log('List partners'); + // Get storefront + log('Get storefront'); try { - await client.partners.list(); - pass('Listed partners'); + await client.storefront.get(); + pass('Got storefront'); } catch (e: unknown) { fail((e as Error).message); } // List agents - log('List agents'); + log('List storefront agents'); try { await client.agents.list(); pass('Listed agents'); @@ -196,7 +196,7 @@ async function testPartner() { } // Get skill.md - log('Get partner skill.md'); + log('Get storefront skill.md'); try { const skill = await client.getSkill(); pass(`${skill.name} v${skill.version} - ${skill.commands.length} commands`); @@ -212,15 +212,15 @@ async function testPersonaGuards() { const buyer = makeClient('buyer'); - log('Buyer cannot access partners (partner persona)'); + log('Buyer cannot access storefront (storefront persona)'); try { - buyer.partners; // eslint-disable-line @typescript-eslint/no-unused-expressions + buyer.storefront; // eslint-disable-line @typescript-eslint/no-unused-expressions fail('Should have thrown'); } catch (e: unknown) { pass(`Threw: ${(e as Error).message}`); } - log('Buyer cannot access agents (partner persona)'); + log('Buyer cannot access agents (storefront persona)'); try { buyer.agents; // eslint-disable-line @typescript-eslint/no-unused-expressions fail('Should have thrown'); @@ -228,11 +228,11 @@ async function testPersonaGuards() { pass(`Threw: ${(e as Error).message}`); } - const partner = makeClient('partner'); + const storefront = makeClient('storefront'); - log('Partner cannot access advertisers (buyer persona)'); + log('Storefront cannot access advertisers (buyer persona)'); try { - partner.advertisers; // eslint-disable-line @typescript-eslint/no-unused-expressions + storefront.advertisers; // eslint-disable-line @typescript-eslint/no-unused-expressions fail('Should have thrown'); } catch (e: unknown) { pass(`Threw: ${(e as Error).message}`); @@ -246,7 +246,7 @@ async function main() { console.log('=========================================='); await testBuyer(); - await testPartner(); + await testStorefront(); await testPersonaGuards(); console.log('\n=========================================='); diff --git a/scripts/test-storefront-workflow.sh b/scripts/test-storefront-workflow.sh new file mode 100755 index 0000000..60cd77c --- /dev/null +++ b/scripts/test-storefront-workflow.sh @@ -0,0 +1,108 @@ +#!/bin/bash + +# Scope3 SDK v2 - Storefront Workflow Test +# Tests the storefront persona flow: storefront, agents, config, skill.md +# +# Usage: +# export SCOPE3_API_KEY=your_storefront_api_key +# ./scripts/test-storefront-workflow.sh +# ./scripts/test-storefront-workflow.sh --staging + +set -euo pipefail + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +CLI="node dist/cli/index.js --persona storefront --format json" +STEP=0 + +if [[ "${1:-}" == "--staging" ]]; then + CLI="$CLI --environment staging" + echo -e "${YELLOW}Using STAGING environment${NC}" +fi + +step() { STEP=$((STEP + 1)); echo -e "\n${BLUE}[$STEP]${NC} $1"; } +pass() { echo -e " ${GREEN}PASS${NC} $1"; } +fail() { echo -e " ${RED}FAIL${NC} $1"; } +warn() { echo -e " ${YELLOW}SKIP${NC} $1"; } + +if [ -z "${SCOPE3_API_KEY:-}" ]; then + echo -e "${RED}Error: SCOPE3_API_KEY not set${NC}" + echo " export SCOPE3_API_KEY=your_api_key" + exit 1 +fi + +echo "" +echo "==========================================" +echo " STOREFRONT WORKFLOW TEST (SDK v2)" +echo "==========================================" + +# -- 1. CLI version -------------------------------------------------------- +step "CLI version check" +RESULT=$(node dist/cli/index.js --cli-version 2>&1) && { + pass "CLI version: $RESULT" +} || { + fail "Could not get CLI version" +} + +# -- 2. Config commands ---------------------------------------------------- +step "Config set/get" +node dist/cli/index.js config set persona storefront > /dev/null 2>&1 && { + pass "Config set persona=storefront" +} || { + warn "Config set failed" +} + +RESULT=$(node dist/cli/index.js config get persona 2>&1) && { + pass "Config get persona: $RESULT" +} || { + warn "Config get failed" +} + +# -- 3. Get storefront ----------------------------------------------------- +step "Get storefront" +RESULT=$($CLI storefront get 2>&1) && { + pass "Got storefront" +} || { + warn "Could not get storefront" +} + +# -- 4. List agents -------------------------------------------------------- +step "List agents" +RESULT=$($CLI agents list 2>&1) && { + pass "Listed agents" +} || { + warn "Could not list agents" +} + +# -- 5. Fetch skill.md ----------------------------------------------------- +step "Fetch storefront skill.md from live API" +RESULT=$(node -e " +const { fetchSkillMd } = require('./dist/skill'); +fetchSkillMd({ persona: 'storefront' }).then(s => { + console.log('Lines: ' + s.split('\n').length); + console.log('OK'); +}).catch(e => { console.error(e.message); process.exit(1); }); +" 2>&1) && { + pass "Fetched storefront skill.md ($RESULT)" +} || { + warn "Could not fetch skill.md (using bundled fallback)" +} + +# -- Summary --------------------------------------------------------------- +echo "" +echo "==========================================" +echo " STOREFRONT WORKFLOW SUMMARY" +echo "==========================================" +echo "" +echo " The storefront persona supports:" +echo " - Storefront management (get, create, update)" +echo " - Agent management (list, get, register, update)" +echo " - Config management" +echo " - skill.md fetching" +echo "" +echo -e "${GREEN}Storefront workflow test complete.${NC}" diff --git a/src/__tests__/resources/audiences.test.ts b/src/__tests__/resources/audiences.test.ts new file mode 100644 index 0000000..49ee52d --- /dev/null +++ b/src/__tests__/resources/audiences.test.ts @@ -0,0 +1,53 @@ +/** + * Tests for AudiencesResource + */ + +import { AudiencesResource } from '../../resources/audiences'; +import type { BaseAdapter } from '../../adapters/base'; + +describe('AudiencesResource', () => { + let mockAdapter: jest.Mocked; + let resource: AudiencesResource; + + beforeEach(() => { + mockAdapter = { + baseUrl: 'https://api.test.com', + version: 'v2', + persona: 'buyer' as const, + debug: false, + validate: false, + request: jest.fn(), + connect: jest.fn(), + disconnect: jest.fn(), + }; + resource = new AudiencesResource(mockAdapter, 'adv-123'); + }); + + describe('sync', () => { + it('should call adapter with correct path and body', async () => { + const data = { audiences: [{ name: 'Retargeting' }] }; + mockAdapter.request.mockResolvedValue({ synced: 1 }); + await resource.sync(data); + expect(mockAdapter.request).toHaveBeenCalledWith( + 'POST', + '/advertisers/adv-123/audiences/sync', + data + ); + }); + }); + + describe('list', () => { + it('should call adapter with correct path when no params', async () => { + mockAdapter.request.mockResolvedValue([]); + await resource.list(); + expect(mockAdapter.request).toHaveBeenCalledWith( + 'GET', + '/advertisers/adv-123/audiences', + undefined, + { + params: { take: undefined, skip: undefined }, + } + ); + }); + }); +}); diff --git a/src/__tests__/resources/catalogs.test.ts b/src/__tests__/resources/catalogs.test.ts new file mode 100644 index 0000000..1b9970b --- /dev/null +++ b/src/__tests__/resources/catalogs.test.ts @@ -0,0 +1,66 @@ +/** + * Tests for CatalogsResource + */ + +import { CatalogsResource } from '../../resources/catalogs'; +import type { BaseAdapter } from '../../adapters/base'; + +describe('CatalogsResource', () => { + let mockAdapter: jest.Mocked; + let resource: CatalogsResource; + + beforeEach(() => { + mockAdapter = { + baseUrl: 'https://api.test.com', + version: 'v2', + persona: 'buyer' as const, + debug: false, + validate: false, + request: jest.fn(), + connect: jest.fn(), + disconnect: jest.fn(), + }; + resource = new CatalogsResource(mockAdapter, 'adv-123'); + }); + + describe('sync', () => { + it('should call adapter with correct path and body', async () => { + const data = { catalogs: [{ name: 'Summer Sale' }] }; + mockAdapter.request.mockResolvedValue({ synced: 1 }); + await resource.sync(data); + expect(mockAdapter.request).toHaveBeenCalledWith( + 'POST', + '/advertisers/adv-123/catalogs/sync', + data + ); + }); + }); + + describe('list', () => { + it('should call adapter with correct path when no params', async () => { + mockAdapter.request.mockResolvedValue([]); + await resource.list(); + expect(mockAdapter.request).toHaveBeenCalledWith( + 'GET', + '/advertisers/adv-123/catalogs', + undefined, + { + params: { type: undefined, take: undefined, skip: undefined }, + } + ); + }); + + it('should call adapter with correct path and params', async () => { + mockAdapter.request.mockResolvedValue([]); + await resource.list({ type: 'product', take: 10 }); + expect(mockAdapter.request).toHaveBeenCalledWith( + 'GET', + '/advertisers/adv-123/catalogs', + undefined, + { + params: { type: 'product', take: 10, skip: undefined }, + } + ); + }); + }); +}); diff --git a/src/__tests__/resources/creatives.test.ts b/src/__tests__/resources/creatives.test.ts new file mode 100644 index 0000000..2957c81 --- /dev/null +++ b/src/__tests__/resources/creatives.test.ts @@ -0,0 +1,115 @@ +/** + * Tests for CreativesResource + */ + +import { CreativesResource } from '../../resources/creatives'; +import type { BaseAdapter } from '../../adapters/base'; + +describe('CreativesResource', () => { + let mockAdapter: jest.Mocked; + let resource: CreativesResource; + + beforeEach(() => { + mockAdapter = { + baseUrl: 'https://api.test.com', + version: 'v2', + persona: 'buyer' as const, + debug: false, + validate: false, + request: jest.fn(), + connect: jest.fn(), + disconnect: jest.fn(), + }; + resource = new CreativesResource(mockAdapter, 'camp-123'); + }); + + describe('list', () => { + it('should call adapter with correct path when no params', async () => { + mockAdapter.request.mockResolvedValue([]); + await resource.list(); + expect(mockAdapter.request).toHaveBeenCalledWith( + 'GET', + '/campaigns/camp-123/creatives', + undefined, + { + params: { + quality: undefined, + search: undefined, + take: undefined, + skip: undefined, + }, + } + ); + }); + + it('should call adapter with correct path and params', async () => { + mockAdapter.request.mockResolvedValue([]); + await resource.list({ quality: 'high', take: 5 }); + expect(mockAdapter.request).toHaveBeenCalledWith( + 'GET', + '/campaigns/camp-123/creatives', + undefined, + { + params: { + quality: 'high', + search: undefined, + take: 5, + skip: undefined, + }, + } + ); + }); + }); + + describe('get', () => { + it('should call adapter with correct path', async () => { + mockAdapter.request.mockResolvedValue({ id: 'cr-1' }); + await resource.get('cr-1'); + expect(mockAdapter.request).toHaveBeenCalledWith( + 'GET', + '/campaigns/camp-123/creatives/cr-1', + undefined, + { + params: undefined, + } + ); + }); + + it('should call adapter with preview param when true', async () => { + mockAdapter.request.mockResolvedValue({ id: 'cr-1', preview: {} }); + await resource.get('cr-1', true); + expect(mockAdapter.request).toHaveBeenCalledWith( + 'GET', + '/campaigns/camp-123/creatives/cr-1', + undefined, + { + params: { preview: true }, + } + ); + }); + }); + + describe('update', () => { + it('should call adapter with correct path and body', async () => { + const data = { name: 'Updated Creative' }; + mockAdapter.request.mockResolvedValue({ id: 'cr-1', name: 'Updated Creative' }); + await resource.update('cr-1', data); + expect(mockAdapter.request).toHaveBeenCalledWith( + 'PUT', + '/campaigns/camp-123/creatives/cr-1', + data + ); + }); + }); + + describe('delete', () => { + it('should call adapter with correct path', async () => { + mockAdapter.request.mockResolvedValue(undefined); + await resource.delete('cr-1'); + expect(mockAdapter.request).toHaveBeenCalledWith( + 'DELETE', + '/campaigns/camp-123/creatives/cr-1' + ); + }); + }); +}); diff --git a/src/__tests__/resources/event-sources.test.ts b/src/__tests__/resources/event-sources.test.ts new file mode 100644 index 0000000..26a2b50 --- /dev/null +++ b/src/__tests__/resources/event-sources.test.ts @@ -0,0 +1,94 @@ +/** + * Tests for EventSourcesResource + */ + +import { EventSourcesResource } from '../../resources/event-sources'; +import type { BaseAdapter } from '../../adapters/base'; + +describe('EventSourcesResource', () => { + let mockAdapter: jest.Mocked; + let resource: EventSourcesResource; + + beforeEach(() => { + mockAdapter = { + baseUrl: 'https://api.test.com', + version: 'v2', + persona: 'buyer' as const, + debug: false, + validate: false, + request: jest.fn(), + connect: jest.fn(), + disconnect: jest.fn(), + }; + resource = new EventSourcesResource(mockAdapter, 'adv-123'); + }); + + describe('sync', () => { + it('should call adapter with correct path and body', async () => { + const data = { sources: [{ name: 'pixel' }] }; + mockAdapter.request.mockResolvedValue({ synced: 1 }); + await resource.sync(data); + expect(mockAdapter.request).toHaveBeenCalledWith( + 'POST', + '/advertisers/adv-123/event-sources/sync', + data + ); + }); + }); + + describe('list', () => { + it('should call adapter with correct path', async () => { + mockAdapter.request.mockResolvedValue([]); + await resource.list(); + expect(mockAdapter.request).toHaveBeenCalledWith('GET', '/advertisers/adv-123/event-sources'); + }); + }); + + describe('create', () => { + it('should call adapter with correct path and body', async () => { + const data = { name: 'new-source', type: 'pixel' }; + mockAdapter.request.mockResolvedValue({ id: 'es-1' }); + await resource.create(data); + expect(mockAdapter.request).toHaveBeenCalledWith( + 'POST', + '/advertisers/adv-123/event-sources', + data + ); + }); + }); + + describe('get', () => { + it('should call adapter with correct path', async () => { + mockAdapter.request.mockResolvedValue({ id: 'es-1' }); + await resource.get('es-1'); + expect(mockAdapter.request).toHaveBeenCalledWith( + 'GET', + '/advertisers/adv-123/event-sources/es-1' + ); + }); + }); + + describe('update', () => { + it('should call adapter with correct path and body', async () => { + const data = { name: 'updated-source' }; + mockAdapter.request.mockResolvedValue({ id: 'es-1', name: 'updated-source' }); + await resource.update('es-1', data); + expect(mockAdapter.request).toHaveBeenCalledWith( + 'PUT', + '/advertisers/adv-123/event-sources/es-1', + data + ); + }); + }); + + describe('delete', () => { + it('should call adapter with correct path', async () => { + mockAdapter.request.mockResolvedValue(undefined); + await resource.delete('es-1'); + expect(mockAdapter.request).toHaveBeenCalledWith( + 'DELETE', + '/advertisers/adv-123/event-sources/es-1' + ); + }); + }); +}); diff --git a/src/__tests__/resources/measurement-data.test.ts b/src/__tests__/resources/measurement-data.test.ts new file mode 100644 index 0000000..830bec5 --- /dev/null +++ b/src/__tests__/resources/measurement-data.test.ts @@ -0,0 +1,38 @@ +/** + * Tests for MeasurementDataResource + */ + +import { MeasurementDataResource } from '../../resources/measurement-data'; +import type { BaseAdapter } from '../../adapters/base'; + +describe('MeasurementDataResource', () => { + let mockAdapter: jest.Mocked; + let resource: MeasurementDataResource; + + beforeEach(() => { + mockAdapter = { + baseUrl: 'https://api.test.com', + version: 'v2', + persona: 'buyer' as const, + debug: false, + validate: false, + request: jest.fn(), + connect: jest.fn(), + disconnect: jest.fn(), + }; + resource = new MeasurementDataResource(mockAdapter, 'adv-123'); + }); + + describe('sync', () => { + it('should call adapter with correct path and body', async () => { + const data = { measurements: [{ metric: 'impressions', value: 100 }] }; + mockAdapter.request.mockResolvedValue({ synced: 1 }); + await resource.sync(data); + expect(mockAdapter.request).toHaveBeenCalledWith( + 'POST', + '/advertisers/adv-123/measurement-data/sync', + data + ); + }); + }); +}); diff --git a/src/__tests__/resources/property-lists.test.ts b/src/__tests__/resources/property-lists.test.ts new file mode 100644 index 0000000..743d16f --- /dev/null +++ b/src/__tests__/resources/property-lists.test.ts @@ -0,0 +1,137 @@ +/** + * Tests for PropertyListsResource and PropertyListChecksResource + */ + +import { PropertyListsResource, PropertyListChecksResource } from '../../resources/property-lists'; +import type { BaseAdapter } from '../../adapters/base'; + +describe('PropertyListsResource', () => { + let mockAdapter: jest.Mocked; + let resource: PropertyListsResource; + + beforeEach(() => { + mockAdapter = { + baseUrl: 'https://api.test.com', + version: 'v2', + persona: 'buyer' as const, + debug: false, + validate: false, + request: jest.fn(), + connect: jest.fn(), + disconnect: jest.fn(), + }; + resource = new PropertyListsResource(mockAdapter, 'adv-123'); + }); + + describe('create', () => { + it('should call adapter with correct path and body', async () => { + const data = { name: 'Blocklist', purpose: 'exclusion' }; + mockAdapter.request.mockResolvedValue({ id: 'pl-1' }); + await resource.create(data); + expect(mockAdapter.request).toHaveBeenCalledWith( + 'POST', + '/advertisers/adv-123/property-lists', + data + ); + }); + }); + + describe('list', () => { + it('should call adapter with correct path when no params', async () => { + mockAdapter.request.mockResolvedValue([]); + await resource.list(); + expect(mockAdapter.request).toHaveBeenCalledWith( + 'GET', + '/advertisers/adv-123/property-lists', + undefined, + { + params: { purpose: undefined }, + } + ); + }); + + it('should call adapter with correct path and params', async () => { + mockAdapter.request.mockResolvedValue([]); + await resource.list({ purpose: 'inclusion' }); + expect(mockAdapter.request).toHaveBeenCalledWith( + 'GET', + '/advertisers/adv-123/property-lists', + undefined, + { + params: { purpose: 'inclusion' }, + } + ); + }); + }); + + describe('get', () => { + it('should call adapter with correct path', async () => { + mockAdapter.request.mockResolvedValue({ id: 'pl-1' }); + await resource.get('pl-1'); + expect(mockAdapter.request).toHaveBeenCalledWith( + 'GET', + '/advertisers/adv-123/property-lists/pl-1' + ); + }); + }); + + describe('update', () => { + it('should call adapter with correct path and body', async () => { + const data = { name: 'Updated Blocklist' }; + mockAdapter.request.mockResolvedValue({ id: 'pl-1', name: 'Updated Blocklist' }); + await resource.update('pl-1', data); + expect(mockAdapter.request).toHaveBeenCalledWith( + 'PUT', + '/advertisers/adv-123/property-lists/pl-1', + data + ); + }); + }); + + describe('delete', () => { + it('should call adapter with correct path', async () => { + mockAdapter.request.mockResolvedValue(undefined); + await resource.delete('pl-1'); + expect(mockAdapter.request).toHaveBeenCalledWith( + 'DELETE', + '/advertisers/adv-123/property-lists/pl-1' + ); + }); + }); +}); + +describe('PropertyListChecksResource', () => { + let mockAdapter: jest.Mocked; + let resource: PropertyListChecksResource; + + beforeEach(() => { + mockAdapter = { + baseUrl: 'https://api.test.com', + version: 'v2', + persona: 'buyer' as const, + debug: false, + validate: false, + request: jest.fn(), + connect: jest.fn(), + disconnect: jest.fn(), + }; + resource = new PropertyListChecksResource(mockAdapter); + }); + + describe('check', () => { + it('should call adapter with correct path and body', async () => { + const data = { domains: ['example.com'] }; + mockAdapter.request.mockResolvedValue({ results: [] }); + await resource.check(data); + expect(mockAdapter.request).toHaveBeenCalledWith('POST', '/property-lists/check', data); + }); + }); + + describe('getReport', () => { + it('should call adapter with correct path', async () => { + mockAdapter.request.mockResolvedValue({ id: 'rpt-1' }); + await resource.getReport('rpt-1'); + expect(mockAdapter.request).toHaveBeenCalledWith('GET', '/property-lists/reports/rpt-1'); + }); + }); +}); diff --git a/src/__tests__/resources/syndication.test.ts b/src/__tests__/resources/syndication.test.ts new file mode 100644 index 0000000..46c13ed --- /dev/null +++ b/src/__tests__/resources/syndication.test.ts @@ -0,0 +1,82 @@ +/** + * Tests for SyndicationResource + */ + +import { SyndicationResource } from '../../resources/syndication'; +import type { BaseAdapter } from '../../adapters/base'; + +describe('SyndicationResource', () => { + let mockAdapter: jest.Mocked; + let resource: SyndicationResource; + + beforeEach(() => { + mockAdapter = { + baseUrl: 'https://api.test.com', + version: 'v2', + persona: 'buyer' as const, + debug: false, + validate: false, + request: jest.fn(), + connect: jest.fn(), + disconnect: jest.fn(), + }; + resource = new SyndicationResource(mockAdapter, 'adv-123'); + }); + + describe('syndicate', () => { + it('should call adapter with correct path and body', async () => { + const data = { resourceType: 'campaign', resourceId: 'camp-1' }; + mockAdapter.request.mockResolvedValue({ taskId: 'task-1' }); + await resource.syndicate(data); + expect(mockAdapter.request).toHaveBeenCalledWith( + 'POST', + '/advertisers/adv-123/syndicate', + data + ); + }); + }); + + describe('status', () => { + it('should call adapter with correct path when no params', async () => { + mockAdapter.request.mockResolvedValue([]); + await resource.status(); + expect(mockAdapter.request).toHaveBeenCalledWith( + 'GET', + '/advertisers/adv-123/syndication-status', + undefined, + { + params: { + resourceType: undefined, + resourceId: undefined, + adcpAgentId: undefined, + enabled: undefined, + status: undefined, + limit: undefined, + offset: undefined, + }, + } + ); + }); + + it('should call adapter with correct path and params', async () => { + mockAdapter.request.mockResolvedValue([]); + await resource.status({ resourceType: 'campaign' }); + expect(mockAdapter.request).toHaveBeenCalledWith( + 'GET', + '/advertisers/adv-123/syndication-status', + undefined, + { + params: { + resourceType: 'campaign', + resourceId: undefined, + adcpAgentId: undefined, + enabled: undefined, + status: undefined, + limit: undefined, + offset: undefined, + }, + } + ); + }); + }); +}); diff --git a/src/__tests__/resources/tasks.test.ts b/src/__tests__/resources/tasks.test.ts new file mode 100644 index 0000000..85e33fc --- /dev/null +++ b/src/__tests__/resources/tasks.test.ts @@ -0,0 +1,33 @@ +/** + * Tests for TasksResource + */ + +import { TasksResource } from '../../resources/tasks'; +import type { BaseAdapter } from '../../adapters/base'; + +describe('TasksResource', () => { + let mockAdapter: jest.Mocked; + let resource: TasksResource; + + beforeEach(() => { + mockAdapter = { + baseUrl: 'https://api.test.com', + version: 'v2', + persona: 'buyer' as const, + debug: false, + validate: false, + request: jest.fn(), + connect: jest.fn(), + disconnect: jest.fn(), + }; + resource = new TasksResource(mockAdapter); + }); + + describe('get', () => { + it('should call adapter with correct path', async () => { + mockAdapter.request.mockResolvedValue({ id: 'task-123', status: 'completed' }); + await resource.get('task-123'); + expect(mockAdapter.request).toHaveBeenCalledWith('GET', '/tasks/task-123'); + }); + }); +}); diff --git a/src/types/index.ts b/src/types/index.ts index 59ea17d..81d524a 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -23,7 +23,7 @@ export type Environment = 'production' | 'staging'; export interface Scope3ClientConfig { /** API key (Bearer token) for authentication */ apiKey: string; - /** API persona - buyer or partner */ + /** API persona - buyer or storefront */ persona: Persona; /** API version to use (default: 'v2') */ version?: ApiVersion; From fd98b548ed8a029b4bf27785ad0496d956dae1f7 Mon Sep 17 00:00:00 2001 From: Emma Mulitz Date: Tue, 31 Mar 2026 11:10:31 -0400 Subject: [PATCH 6/6] fix: add missing types, fix stale partner refs, and improve test coverage - Add 23 new type definitions for all untyped resources (EventSource, Catalog, Audience, Creative, Task, PropertyList, Syndication, etc.) - Replace all Promise returns with properly typed responses - Rename CLI partners.ts to storefront.ts, update imports - Fix README stale test:partner reference - Fix storefront-guide and buyer-guide doc examples to match actual types - Add 18 new client tests for tasks, propertyListChecks, buyer cross-access guards, and all new sub-resource accessors --- .npmignore | 2 +- README.md | 2 +- docs/buyer-guide.md | 3 +- docs/storefront-guide.md | 2 +- src/__tests__/client.test.ts | 121 ++++++++++ src/cli/commands/index.ts | 2 +- .../commands/{partners.ts => storefront.ts} | 0 src/index.ts | 32 +++ src/resources/agents.ts | 11 +- src/resources/audiences.ts | 9 +- src/resources/billing.ts | 61 +++-- src/resources/catalogs.ts | 13 +- src/resources/creatives.ts | 15 +- src/resources/event-sources.ts | 28 ++- src/resources/inventory-sources.ts | 4 +- src/resources/measurement-data.ts | 5 +- src/resources/notifications.ts | 6 +- src/resources/property-lists.ts | 41 ++-- src/resources/sales-agents.ts | 32 ++- src/resources/syndication.ts | 9 +- src/resources/tasks.ts | 5 +- src/types/index.ts | 216 +++++++++++++++++- 22 files changed, 527 insertions(+), 92 deletions(-) rename src/cli/commands/{partners.ts => storefront.ts} (100%) diff --git a/.npmignore b/.npmignore index 7cf8508..52d2557 100644 --- a/.npmignore +++ b/.npmignore @@ -43,7 +43,7 @@ jest.config.js openapi.yaml media-agent-openapi.yaml outcome-agent-openapi.yaml -partner-api.yaml +storefront-api.yaml platform-api.yaml # Test files diff --git a/README.md b/README.md index 7c51448..41562f7 100644 --- a/README.md +++ b/README.md @@ -240,7 +240,7 @@ The SDK is manually maintained. When the Agentic API changes, update these files 4. Update resource methods in `src/resources/` for endpoint changes 5. Update CLI commands in `src/cli/commands/` if applicable 6. Run `npm test` and `npm run build` to verify -7. Run manual workflow tests: `npm run test:buyer`, `npm run test:partner` +7. Run manual workflow tests: `npm run test:buyer`, `npm run test:storefront` ### Integration Tests diff --git a/docs/buyer-guide.md b/docs/buyer-guide.md index 08584dd..d7006a2 100644 --- a/docs/buyer-guide.md +++ b/docs/buyer-guide.md @@ -260,7 +260,8 @@ const agents = await client.salesAgents.list(); // Register an account for an agent await client.salesAgents.registerAccount('agent-123', { - name: 'My Account', + advertiserId: 'adv-123', + accountIdentifier: 'my-account-id', }); ``` diff --git a/docs/storefront-guide.md b/docs/storefront-guide.md index 5f476f2..5044f3c 100644 --- a/docs/storefront-guide.md +++ b/docs/storefront-guide.md @@ -27,8 +27,8 @@ const sf = await client.storefront.get(); ```typescript const sf = await client.storefront.create({ + platformId: 'my-platform', name: 'My Storefront', - description: 'Ad tech storefront', }); ``` diff --git a/src/__tests__/client.test.ts b/src/__tests__/client.test.ts index 5c7c67c..bc5fd1a 100644 --- a/src/__tests__/client.test.ts +++ b/src/__tests__/client.test.ts @@ -7,6 +7,15 @@ import { ConversionEventsResource } from '../resources/conversion-events'; import { CreativeSetsResource } from '../resources/creative-sets'; import { TestCohortsResource } from '../resources/test-cohorts'; import { BundleProductsResource } from '../resources/products'; +import { TasksResource } from '../resources/tasks'; +import { PropertyListChecksResource } from '../resources/property-lists'; +import { EventSourcesResource } from '../resources/event-sources'; +import { MeasurementDataResource } from '../resources/measurement-data'; +import { CatalogsResource } from '../resources/catalogs'; +import { AudiencesResource } from '../resources/audiences'; +import { SyndicationResource } from '../resources/syndication'; +import { PropertyListsResource } from '../resources/property-lists'; +import { CreativesResource } from '../resources/creatives'; jest.mock('../skill', () => ({ fetchSkillMd: jest.fn(), @@ -149,6 +158,21 @@ describe('Scope3Client', () => { expect(typeof client.salesAgents.list).toBe('function'); expect(typeof client.salesAgents.registerAccount).toBe('function'); }); + + it('should have tasks resource', () => { + const client = new Scope3Client({ apiKey: 'test-key', persona: 'buyer' }); + expect(client.tasks).toBeDefined(); + expect(client.tasks).toBeInstanceOf(TasksResource); + expect(typeof client.tasks.get).toBe('function'); + }); + + it('should have propertyListChecks resource', () => { + const client = new Scope3Client({ apiKey: 'test-key', persona: 'buyer' }); + expect(client.propertyListChecks).toBeDefined(); + expect(client.propertyListChecks).toBeInstanceOf(PropertyListChecksResource); + expect(typeof client.propertyListChecks.check).toBe('function'); + expect(typeof client.propertyListChecks.getReport).toBe('function'); + }); }); describe('storefront persona resources', () => { @@ -218,6 +242,46 @@ describe('Scope3Client', () => { }); }); + describe('buyer persona cannot access storefront resources', () => { + it('should throw when accessing storefront', () => { + const client = new Scope3Client({ apiKey: 'test-key', persona: 'buyer' }); + expect(() => client.storefront).toThrow( + 'storefront is only available with the storefront persona' + ); + }); + + it('should throw when accessing inventorySources', () => { + const client = new Scope3Client({ apiKey: 'test-key', persona: 'buyer' }); + expect(() => client.inventorySources).toThrow( + 'inventorySources is only available with the storefront persona' + ); + }); + + it('should throw when accessing agents', () => { + const client = new Scope3Client({ apiKey: 'test-key', persona: 'buyer' }); + expect(() => client.agents).toThrow('agents is only available with the storefront persona'); + }); + + it('should throw when accessing readiness', () => { + const client = new Scope3Client({ apiKey: 'test-key', persona: 'buyer' }); + expect(() => client.readiness).toThrow( + 'readiness is only available with the storefront persona' + ); + }); + + it('should throw when accessing billing', () => { + const client = new Scope3Client({ apiKey: 'test-key', persona: 'buyer' }); + expect(() => client.billing).toThrow('billing is only available with the storefront persona'); + }); + + it('should throw when accessing notifications', () => { + const client = new Scope3Client({ apiKey: 'test-key', persona: 'buyer' }); + expect(() => client.notifications).toThrow( + 'notifications is only available with the storefront persona' + ); + }); + }); + describe('version handling', () => { it('should support latest version', () => { const client = new Scope3Client({ apiKey: 'test-key', persona: 'buyer', version: 'latest' }); @@ -420,6 +484,63 @@ describe('Scope3Client', () => { const b = client.advertisers.conversionEvents('adv-2'); expect(a).not.toBe(b); }); + + it('eventSources() returns an EventSourcesResource', () => { + const resource = client.advertisers.eventSources('adv-123'); + expect(resource).toBeInstanceOf(EventSourcesResource); + }); + + it('measurementData() returns a MeasurementDataResource', () => { + const resource = client.advertisers.measurementData('adv-123'); + expect(resource).toBeInstanceOf(MeasurementDataResource); + }); + + it('catalogs() returns a CatalogsResource', () => { + const resource = client.advertisers.catalogs('adv-123'); + expect(resource).toBeInstanceOf(CatalogsResource); + }); + + it('audiences() returns an AudiencesResource', () => { + const resource = client.advertisers.audiences('adv-123'); + expect(resource).toBeInstanceOf(AudiencesResource); + }); + + it('syndication() returns a SyndicationResource', () => { + const resource = client.advertisers.syndication('adv-123'); + expect(resource).toBeInstanceOf(SyndicationResource); + }); + + it('propertyLists() returns a PropertyListsResource', () => { + const resource = client.advertisers.propertyLists('adv-123'); + expect(resource).toBeInstanceOf(PropertyListsResource); + }); + }); + + describe('campaigns sub-resources', () => { + let client: Scope3Client; + + beforeEach(() => { + client = new Scope3Client({ apiKey: 'test-key', persona: 'buyer' }); + }); + + it('creatives() returns a CreativesResource', () => { + const resource = client.campaigns.creatives('camp-123'); + expect(resource).toBeInstanceOf(CreativesResource); + }); + + it('creatives() has list, get, update, delete methods', () => { + const resource = client.campaigns.creatives('camp-123'); + expect(typeof resource.list).toBe('function'); + expect(typeof resource.get).toBe('function'); + expect(typeof resource.update).toBe('function'); + expect(typeof resource.delete).toBe('function'); + }); + + it('returns a new resource instance each call', () => { + const a = client.campaigns.creatives('camp-123'); + const b = client.campaigns.creatives('camp-123'); + expect(a).not.toBe(b); + }); }); describe('bundles sub-resources', () => { diff --git a/src/cli/commands/index.ts b/src/cli/commands/index.ts index a782dc8..8dffd9e 100644 --- a/src/cli/commands/index.ts +++ b/src/cli/commands/index.ts @@ -8,7 +8,7 @@ export { campaignsCommand } from './campaigns'; export { configCommand } from './config'; export { conversionEventsCommand } from './conversion-events'; export { creativeSetsCommand } from './creative-sets'; -export { storefrontCommand, agentsCommand } from './partners'; +export { storefrontCommand, agentsCommand } from './storefront'; export { reportingCommand } from './reporting'; export { salesAgentsCommand } from './sales-agents'; export { loginCommand, logoutCommand } from './login'; diff --git a/src/cli/commands/partners.ts b/src/cli/commands/storefront.ts similarity index 100% rename from src/cli/commands/partners.ts rename to src/cli/commands/storefront.ts diff --git a/src/index.ts b/src/index.ts index 843e7a2..cc49556 100644 --- a/src/index.ts +++ b/src/index.ts @@ -216,4 +216,36 @@ export type { ListAgentsParams, OAuthAuthorizeResponse, OAuthCallbackInput, + // Event Sources + EventSource, + CreateEventSourceInput, + UpdateEventSourceInput, + // Measurement Data + MeasurementDataSync, + // Catalogs + Catalog, + CatalogSync, + // Audiences + Audience, + AudienceSync, + // Syndication + SyndicationRequest, + SyndicationStatus, + // Creatives + Creative, + CreateCreativeInput, + UpdateCreativeInput, + // Tasks + Task, + // Property Lists + PropertyList, + CreatePropertyListInput, + UpdatePropertyListInput, + PropertyListCheck, + PropertyListReport, + // Billing + BillingStatus, + BillingTransaction, + BillingPayout, + ListBillingParams, } from './types'; diff --git a/src/resources/agents.ts b/src/resources/agents.ts index 3aff0a5..4dadbbb 100644 --- a/src/resources/agents.ts +++ b/src/resources/agents.ts @@ -23,8 +23,8 @@ export class AgentsResource { * @param params Filter parameters * @returns List of agents */ - async list(params?: ListAgentsParams): Promise { - return this.adapter.request('GET', '/agents', undefined, { + async list(params?: ListAgentsParams): Promise> { + return this.adapter.request>('GET', '/agents', undefined, { params: { type: params?.type, status: params?.status, @@ -91,8 +91,11 @@ export class AgentsResource { * @param data Code and state from OAuth callback * @returns Exchange result */ - async exchangeOAuthCode(agentId: string, data: OAuthCallbackInput): Promise { - return this.adapter.request( + async exchangeOAuthCode( + agentId: string, + data: OAuthCallbackInput + ): Promise>> { + return this.adapter.request>>( 'POST', `/agents/${validateResourceId(agentId)}/oauth/callback`, data diff --git a/src/resources/audiences.ts b/src/resources/audiences.ts index 85e1fc1..d945eb1 100644 --- a/src/resources/audiences.ts +++ b/src/resources/audiences.ts @@ -4,6 +4,7 @@ */ import { type BaseAdapter, validateResourceId } from '../adapters/base'; +import type { ApiResponse, Audience, AudienceSync } from '../types'; /** * Resource for managing audiences (scoped to an advertiser) @@ -19,8 +20,8 @@ export class AudiencesResource { * @param data Audiences sync payload * @returns Sync result */ - async sync(data: unknown): Promise { - return this.adapter.request( + async sync(data: AudienceSync): Promise> { + return this.adapter.request>( 'POST', `/advertisers/${validateResourceId(this.advertiserId)}/audiences/sync`, data @@ -32,8 +33,8 @@ export class AudiencesResource { * @param params Optional pagination parameters * @returns List of audiences */ - async list(params?: { take?: number; skip?: number }): Promise { - return this.adapter.request( + async list(params?: { take?: number; skip?: number }): Promise> { + return this.adapter.request>( 'GET', `/advertisers/${validateResourceId(this.advertiserId)}/audiences`, undefined, diff --git a/src/resources/billing.ts b/src/resources/billing.ts index 617139f..9641e5b 100644 --- a/src/resources/billing.ts +++ b/src/resources/billing.ts @@ -3,7 +3,14 @@ */ import { type BaseAdapter } from '../adapters/base'; -import type { StorefrontBillingConfig, StripeConnectResponse } from '../types'; +import type { + StorefrontBillingConfig, + StripeConnectResponse, + BillingStatus, + BillingTransaction, + BillingPayout, + ApiResponse, +} from '../types'; /** * Resource for managing billing (Storefront persona) @@ -31,8 +38,8 @@ export class BillingResource { * Get billing status * @returns Billing status */ - async status(): Promise { - return this.adapter.request('GET', '/billing/status'); + async status(): Promise { + return this.adapter.request('GET', '/billing/status'); } /** @@ -40,13 +47,21 @@ export class BillingResource { * @param params Pagination parameters * @returns List of transactions */ - async transactions(params?: { limit?: number; starting_after?: string }): Promise { - return this.adapter.request('GET', '/billing/transactions', undefined, { - params: { - limit: params?.limit, - starting_after: params?.starting_after, - }, - }); + async transactions(params?: { + limit?: number; + starting_after?: string; + }): Promise> { + return this.adapter.request>( + 'GET', + '/billing/transactions', + undefined, + { + params: { + limit: params?.limit, + starting_after: params?.starting_after, + }, + } + ); } /** @@ -54,20 +69,28 @@ export class BillingResource { * @param params Pagination parameters * @returns List of payouts */ - async payouts(params?: { limit?: number; starting_after?: string }): Promise { - return this.adapter.request('GET', '/billing/payouts', undefined, { - params: { - limit: params?.limit, - starting_after: params?.starting_after, - }, - }); + async payouts(params?: { + limit?: number; + starting_after?: string; + }): Promise> { + return this.adapter.request>( + 'GET', + '/billing/payouts', + undefined, + { + params: { + limit: params?.limit, + starting_after: params?.starting_after, + }, + } + ); } /** * Get Stripe onboarding URL * @returns Onboarding URL details */ - async onboardingUrl(): Promise { - return this.adapter.request('GET', '/billing/onboard'); + async onboardingUrl(): Promise { + return this.adapter.request('GET', '/billing/onboard'); } } diff --git a/src/resources/catalogs.ts b/src/resources/catalogs.ts index bd08228..791f839 100644 --- a/src/resources/catalogs.ts +++ b/src/resources/catalogs.ts @@ -4,6 +4,7 @@ */ import { type BaseAdapter, validateResourceId } from '../adapters/base'; +import type { ApiResponse, Catalog, CatalogSync } from '../types'; /** * Resource for managing catalogs (scoped to an advertiser) @@ -19,8 +20,8 @@ export class CatalogsResource { * @param data Catalogs sync payload * @returns Sync result */ - async sync(data: unknown): Promise { - return this.adapter.request( + async sync(data: CatalogSync): Promise> { + return this.adapter.request>( 'POST', `/advertisers/${validateResourceId(this.advertiserId)}/catalogs/sync`, data @@ -32,8 +33,12 @@ export class CatalogsResource { * @param params Optional filter and pagination parameters * @returns List of catalogs */ - async list(params?: { type?: string; take?: number; skip?: number }): Promise { - return this.adapter.request( + async list(params?: { + type?: string; + take?: number; + skip?: number; + }): Promise> { + return this.adapter.request>( 'GET', `/advertisers/${validateResourceId(this.advertiserId)}/catalogs`, undefined, diff --git a/src/resources/creatives.ts b/src/resources/creatives.ts index c9825f1..7c0e58f 100644 --- a/src/resources/creatives.ts +++ b/src/resources/creatives.ts @@ -4,6 +4,7 @@ */ import { type BaseAdapter, validateResourceId } from '../adapters/base'; +import type { ApiResponse, Creative, UpdateCreativeInput } from '../types'; /** * Resource for managing creatives (scoped to a campaign) @@ -24,8 +25,8 @@ export class CreativesResource { search?: string; take?: number; skip?: number; - }): Promise { - return this.adapter.request( + }): Promise> { + return this.adapter.request>( 'GET', `/campaigns/${validateResourceId(this.campaignId)}/creatives`, undefined, @@ -46,8 +47,8 @@ export class CreativesResource { * @param preview Whether to include preview data * @returns Creative details */ - async get(creativeId: string, preview?: boolean): Promise { - return this.adapter.request( + async get(creativeId: string, preview?: boolean): Promise> { + return this.adapter.request>( 'GET', `/campaigns/${validateResourceId(this.campaignId)}/creatives/${validateResourceId(creativeId)}`, undefined, @@ -63,8 +64,8 @@ export class CreativesResource { * @param data Update data * @returns Updated creative */ - async update(creativeId: string, data: unknown): Promise { - return this.adapter.request( + async update(creativeId: string, data: UpdateCreativeInput): Promise> { + return this.adapter.request>( 'PUT', `/campaigns/${validateResourceId(this.campaignId)}/creatives/${validateResourceId(creativeId)}`, data @@ -76,7 +77,7 @@ export class CreativesResource { * @param creativeId Creative ID */ async delete(creativeId: string): Promise { - await this.adapter.request( + await this.adapter.request( 'DELETE', `/campaigns/${validateResourceId(this.campaignId)}/creatives/${validateResourceId(creativeId)}` ); diff --git a/src/resources/event-sources.ts b/src/resources/event-sources.ts index f539beb..faed663 100644 --- a/src/resources/event-sources.ts +++ b/src/resources/event-sources.ts @@ -4,6 +4,12 @@ */ import { type BaseAdapter, validateResourceId } from '../adapters/base'; +import type { + ApiResponse, + EventSource, + CreateEventSourceInput, + UpdateEventSourceInput, +} from '../types'; /** * Resource for managing event sources (scoped to an advertiser) @@ -19,8 +25,8 @@ export class EventSourcesResource { * @param data Event sources sync payload * @returns Sync result */ - async sync(data: unknown): Promise { - return this.adapter.request( + async sync(data: Record): Promise> { + return this.adapter.request>( 'POST', `/advertisers/${validateResourceId(this.advertiserId)}/event-sources/sync`, data @@ -31,8 +37,8 @@ export class EventSourcesResource { * List all event sources for this advertiser * @returns List of event sources */ - async list(): Promise { - return this.adapter.request( + async list(): Promise> { + return this.adapter.request>( 'GET', `/advertisers/${validateResourceId(this.advertiserId)}/event-sources` ); @@ -43,8 +49,8 @@ export class EventSourcesResource { * @param data Event source creation data * @returns Created event source */ - async create(data: unknown): Promise { - return this.adapter.request( + async create(data: CreateEventSourceInput): Promise> { + return this.adapter.request>( 'POST', `/advertisers/${validateResourceId(this.advertiserId)}/event-sources`, data @@ -56,8 +62,8 @@ export class EventSourcesResource { * @param id Event source ID * @returns Event source details */ - async get(id: string): Promise { - return this.adapter.request( + async get(id: string): Promise> { + return this.adapter.request>( 'GET', `/advertisers/${validateResourceId(this.advertiserId)}/event-sources/${validateResourceId(id)}` ); @@ -69,8 +75,8 @@ export class EventSourcesResource { * @param data Update data * @returns Updated event source */ - async update(id: string, data: unknown): Promise { - return this.adapter.request( + async update(id: string, data: UpdateEventSourceInput): Promise> { + return this.adapter.request>( 'PUT', `/advertisers/${validateResourceId(this.advertiserId)}/event-sources/${validateResourceId(id)}`, data @@ -82,7 +88,7 @@ export class EventSourcesResource { * @param id Event source ID */ async delete(id: string): Promise { - await this.adapter.request( + await this.adapter.request( 'DELETE', `/advertisers/${validateResourceId(this.advertiserId)}/event-sources/${validateResourceId(id)}` ); diff --git a/src/resources/inventory-sources.ts b/src/resources/inventory-sources.ts index 6ccd5e3..26d3030 100644 --- a/src/resources/inventory-sources.ts +++ b/src/resources/inventory-sources.ts @@ -20,8 +20,8 @@ export class InventorySourcesResource { * List all inventory sources * @returns List of inventory sources */ - async list(): Promise { - return this.adapter.request('GET', '/inventory-sources'); + async list(): Promise> { + return this.adapter.request>('GET', '/inventory-sources'); } /** diff --git a/src/resources/measurement-data.ts b/src/resources/measurement-data.ts index fb0532c..4ed9ec0 100644 --- a/src/resources/measurement-data.ts +++ b/src/resources/measurement-data.ts @@ -4,6 +4,7 @@ */ import { type BaseAdapter, validateResourceId } from '../adapters/base'; +import type { ApiResponse, MeasurementDataSync } from '../types'; /** * Resource for managing measurement data (scoped to an advertiser) @@ -19,8 +20,8 @@ export class MeasurementDataResource { * @param data Measurement data sync payload * @returns Sync result */ - async sync(data: unknown): Promise { - return this.adapter.request( + async sync(data: MeasurementDataSync): Promise> { + return this.adapter.request>( 'POST', `/advertisers/${validateResourceId(this.advertiserId)}/measurement-data/sync`, data diff --git a/src/resources/notifications.ts b/src/resources/notifications.ts index 1e1d0d9..53b5e0f 100644 --- a/src/resources/notifications.ts +++ b/src/resources/notifications.ts @@ -3,7 +3,7 @@ */ import { type BaseAdapter, validateResourceId } from '../adapters/base'; -import type { ListNotificationsParams } from '../types'; +import type { ApiResponse, Notification, ListNotificationsParams } from '../types'; /** * Resource for managing notifications (Storefront persona) @@ -16,8 +16,8 @@ export class NotificationsResource { * @param params Filter and pagination parameters * @returns List of notifications */ - async list(params?: ListNotificationsParams): Promise { - return this.adapter.request('GET', '/notifications', undefined, { + async list(params?: ListNotificationsParams): Promise> { + return this.adapter.request>('GET', '/notifications', undefined, { params: { unreadOnly: params?.unreadOnly, brandAgentId: params?.brandAgentId, diff --git a/src/resources/property-lists.ts b/src/resources/property-lists.ts index b5a8b96..12d3741 100644 --- a/src/resources/property-lists.ts +++ b/src/resources/property-lists.ts @@ -4,6 +4,14 @@ */ import { type BaseAdapter, validateResourceId } from '../adapters/base'; +import type { + ApiResponse, + PropertyList, + CreatePropertyListInput, + UpdatePropertyListInput, + PropertyListCheck, + PropertyListReport, +} from '../types'; /** * Resource for managing property lists (scoped to an advertiser) @@ -19,8 +27,8 @@ export class PropertyListsResource { * @param data Property list creation data * @returns Created property list */ - async create(data: unknown): Promise { - return this.adapter.request( + async create(data: CreatePropertyListInput): Promise> { + return this.adapter.request>( 'POST', `/advertisers/${validateResourceId(this.advertiserId)}/property-lists`, data @@ -32,8 +40,8 @@ export class PropertyListsResource { * @param params Optional filter parameters * @returns List of property lists */ - async list(params?: { purpose?: string }): Promise { - return this.adapter.request( + async list(params?: { purpose?: string }): Promise> { + return this.adapter.request>( 'GET', `/advertisers/${validateResourceId(this.advertiserId)}/property-lists`, undefined, @@ -48,8 +56,8 @@ export class PropertyListsResource { * @param listId Property list ID * @returns Property list details */ - async get(listId: string): Promise { - return this.adapter.request( + async get(listId: string): Promise> { + return this.adapter.request>( 'GET', `/advertisers/${validateResourceId(this.advertiserId)}/property-lists/${validateResourceId(listId)}` ); @@ -61,8 +69,8 @@ export class PropertyListsResource { * @param data Update data * @returns Updated property list */ - async update(listId: string, data: unknown): Promise { - return this.adapter.request( + async update(listId: string, data: UpdatePropertyListInput): Promise> { + return this.adapter.request>( 'PUT', `/advertisers/${validateResourceId(this.advertiserId)}/property-lists/${validateResourceId(listId)}`, data @@ -74,7 +82,7 @@ export class PropertyListsResource { * @param listId Property list ID */ async delete(listId: string): Promise { - await this.adapter.request( + await this.adapter.request( 'DELETE', `/advertisers/${validateResourceId(this.advertiserId)}/property-lists/${validateResourceId(listId)}` ); @@ -92,8 +100,12 @@ export class PropertyListChecksResource { * @param data Domains to check * @returns Check result */ - async check(data: { domains: string[] }): Promise { - return this.adapter.request('POST', '/property-lists/check', data); + async check(data: { domains: string[] }): Promise> { + return this.adapter.request>( + 'POST', + '/property-lists/check', + data + ); } /** @@ -101,7 +113,10 @@ export class PropertyListChecksResource { * @param reportId Report ID * @returns Check report details */ - async getReport(reportId: string): Promise { - return this.adapter.request('GET', `/property-lists/reports/${validateResourceId(reportId)}`); + async getReport(reportId: string): Promise> { + return this.adapter.request>( + 'GET', + `/property-lists/reports/${validateResourceId(reportId)}` + ); } } diff --git a/src/resources/sales-agents.ts b/src/resources/sales-agents.ts index 589d574..28d515a 100644 --- a/src/resources/sales-agents.ts +++ b/src/resources/sales-agents.ts @@ -4,9 +4,11 @@ import { type BaseAdapter, validateResourceId } from '../adapters/base'; import type { + SalesAgent, SalesAgentAccount, ListSalesAgentsParams, RegisterSalesAgentAccountInput, + PaginatedApiResponse, } from '../types'; import { salesAgentSchemas } from '../schemas/registry'; import { @@ -27,18 +29,26 @@ export class SalesAgentsResource { * @param params Filter and pagination parameters * @returns Sales agents with account info */ - async list(params?: ListSalesAgentsParams): Promise { - let result = await this.adapter.request('GET', '/sales-agents', undefined, { - params: { - status: params?.status, - relationship: params?.relationship, - name: params?.name, - limit: params?.limit, - offset: params?.offset, - }, - }); + async list(params?: ListSalesAgentsParams): Promise> { + let result = await this.adapter.request>( + 'GET', + '/sales-agents', + undefined, + { + params: { + status: params?.status, + relationship: params?.relationship, + name: params?.name, + limit: params?.limit, + offset: params?.offset, + }, + } + ); if (shouldValidateResponse(this.adapter.validate)) { - result = validateResponse(salesAgentSchemas.listResponse, result); + result = validateResponse( + salesAgentSchemas.listResponse, + result + ) as unknown as PaginatedApiResponse; } return result; } diff --git a/src/resources/syndication.ts b/src/resources/syndication.ts index a7e8c73..d9acb6d 100644 --- a/src/resources/syndication.ts +++ b/src/resources/syndication.ts @@ -4,6 +4,7 @@ */ import { type BaseAdapter, validateResourceId } from '../adapters/base'; +import type { ApiResponse, SyndicationRequest, SyndicationStatus } from '../types'; /** * Resource for managing syndication (scoped to an advertiser) @@ -19,8 +20,8 @@ export class SyndicationResource { * @param data Syndication request payload * @returns Syndication result */ - async syndicate(data: unknown): Promise { - return this.adapter.request( + async syndicate(data: SyndicationRequest): Promise> { + return this.adapter.request>( 'POST', `/advertisers/${validateResourceId(this.advertiserId)}/syndicate`, data @@ -40,8 +41,8 @@ export class SyndicationResource { status?: string; limit?: number; offset?: number; - }): Promise { - return this.adapter.request( + }): Promise> { + return this.adapter.request>( 'GET', `/advertisers/${validateResourceId(this.advertiserId)}/syndication-status`, undefined, diff --git a/src/resources/tasks.ts b/src/resources/tasks.ts index 2865ed7..c116f0c 100644 --- a/src/resources/tasks.ts +++ b/src/resources/tasks.ts @@ -4,6 +4,7 @@ */ import { type BaseAdapter, validateResourceId } from '../adapters/base'; +import type { ApiResponse, Task } from '../types'; /** * Resource for managing tasks (Buyer persona, top-level) @@ -16,7 +17,7 @@ export class TasksResource { * @param taskId Task ID * @returns Task status details */ - async get(taskId: string): Promise { - return this.adapter.request('GET', `/tasks/${validateResourceId(taskId)}`); + async get(taskId: string): Promise> { + return this.adapter.request>('GET', `/tasks/${validateResourceId(taskId)}`); } } diff --git a/src/types/index.ts b/src/types/index.ts index 81d524a..ad65dc4 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -753,7 +753,7 @@ export interface ReadinessCheck { name: string; description: string; category: string; - status: string; + status: ReadinessStatus; isBlocker: boolean; details?: string; } @@ -884,3 +884,217 @@ export interface OAuthCallbackInput { code: string; state: string; } + +// ============================================================================ +// Event Source Types (Buyer Persona) +// ============================================================================ + +export interface EventSource { + id: string; + advertiserId: string; + name: string; + type: string; + status: string; + config: Record; + createdAt: string; + updatedAt: string; +} + +export interface CreateEventSourceInput { + name: string; + type: string; + config?: Record; +} + +export interface UpdateEventSourceInput { + name?: string; + type?: string; + config?: Record; +} + +// ============================================================================ +// Measurement Data Types (Buyer Persona) +// ============================================================================ + +export interface MeasurementDataSync { + type?: string; + source?: string; + data?: Record; + measurements?: Record[]; + [key: string]: unknown; +} + +// ============================================================================ +// Catalog Types (Buyer Persona) +// ============================================================================ + +export interface Catalog { + id: string; + advertiserId: string; + name: string; + status: string; + itemCount: number; + createdAt: string; + updatedAt: string; +} + +export interface CatalogSync { + source?: string; + data?: Record; + catalogs?: Record[]; + [key: string]: unknown; +} + +// ============================================================================ +// Audience Types (Buyer Persona) +// ============================================================================ + +export interface Audience { + id: string; + advertiserId: string; + name: string; + size: number; + status: string; + createdAt: string; + updatedAt: string; +} + +export interface AudienceSync { + source?: string; + data?: Record; + audiences?: Record[]; + [key: string]: unknown; +} + +// ============================================================================ +// Syndication Types (Buyer Persona) +// ============================================================================ + +export interface SyndicationRequest { + targets?: string[]; + resourceType?: string; + resourceId?: string; + config?: Record; + [key: string]: unknown; +} + +export interface SyndicationStatus { + id: string; + status: string; + targets: string[]; + createdAt: string; +} + +// ============================================================================ +// Creative Types (Buyer Persona) +// ============================================================================ + +export interface Creative { + id: string; + campaignId: string; + name: string; + type: string; + status: string; + metadata?: Record; + createdAt: string; + updatedAt: string; +} + +export interface CreateCreativeInput { + name: string; + type: string; + metadata?: Record; +} + +export interface UpdateCreativeInput { + name?: string; + type?: string; + metadata?: Record; +} + +// ============================================================================ +// Task Types (Buyer Persona) +// ============================================================================ + +export interface Task { + id: string; + type: string; + status: string; + progress?: number; + result?: Record; + error?: string; + createdAt: string; + updatedAt: string; +} + +// ============================================================================ +// Property List Types (Buyer Persona) +// ============================================================================ + +export interface PropertyList { + id: string; + advertiserId: string; + name: string; + properties: string[]; + createdAt: string; + updatedAt: string; +} + +export interface CreatePropertyListInput { + name: string; + properties?: string[]; + purpose?: string; + [key: string]: unknown; +} + +export interface UpdatePropertyListInput { + name?: string; + properties?: string[]; +} + +export interface PropertyListCheck { + id: string; + status: string; + results?: Record; + createdAt: string; +} + +export interface PropertyListReport { + id: string; + checkId: string; + data: Record; + createdAt: string; +} + +// ============================================================================ +// Billing Types (Storefront Persona) +// ============================================================================ + +export interface BillingStatus { + status: string; + connected: boolean; + accountId?: string; +} + +export interface BillingTransaction { + id: string; + amount: number; + currency: string; + description: string; + createdAt: string; +} + +export interface BillingPayout { + id: string; + amount: number; + currency: string; + status: string; + createdAt: string; +} + +export interface ListBillingParams { + limit?: number; + offset?: number; + startDate?: string; + endDate?: string; +}