diff --git a/.changeset/comply-controller-seller-scaffold.md b/.changeset/comply-controller-seller-scaffold.md new file mode 100644 index 000000000..3ad7e91d5 --- /dev/null +++ b/.changeset/comply-controller-seller-scaffold.md @@ -0,0 +1,58 @@ +--- +'@adcp/client': minor +--- + +Add `createComplyController` to `@adcp/client/testing` — a domain-grouped +seller-side scaffold for the `comply_test_controller` tool. Takes typed +`seed` / `force` / `simulate` adapters and returns `{ toolDefinition, +handle, handleRaw, register }` so a seller can wire the tool with a single +`controller.register(server)` call. + +```ts +import { createComplyController } from '@adcp/client/testing'; + +const controller = createComplyController({ + // Gate on something the SERVER controls — env var, resolved tenant flag, + // TLS SNI match. Never trust caller-supplied fields like input.ext. + sandboxGate: () => process.env.ADCP_SANDBOX === '1', + seed: { + product: ({ product_id, fixture }) => productRepo.upsert(product_id, fixture), + creative: ({ creative_id, fixture }) => creativeRepo.upsert(creative_id, fixture), + }, + force: { + creative_status: ({ creative_id, status }) => creativeRepo.transition(creative_id, status), + }, +}); +controller.register(server); +``` + +The helper owns scenario dispatch, param validation, typed error +envelopes (`UNKNOWN_SCENARIO`, `INVALID_PARAMS`, `FORBIDDEN`), MCP +response shaping, and seed re-seed idempotency (same id + equivalent +fixture returns `previous_state: "existing"`; divergent fixture returns +`INVALID_PARAMS` without touching the adapter). Transition enforcement +stays adapter-side so the controller and the production path share a +single state machine. + +Hardened against common misuse: sandbox gate requires strict `=== true` +(a gate that returns a truthy non-boolean denies, not allows); fixture +keys `__proto__` / `constructor` / `prototype` are rejected with +`INVALID_PARAMS`; the default seed-fixture cache is capped at 1000 +net-new keys to bound memory under adversarial seeding; and the +`toolDefinition.inputSchema` is shallow-copied so multiple controllers +on one process don't share a mutable shape. + +`list_scenarios` bypasses the sandbox gate so capability probes always +succeed — buyer tooling can distinguish "controller exists but locked" +from "controller missing", while state-mutating scenarios remain gated. +`register()` emits a `console.warn` when no `sandboxGate` is configured +and no `ADCP_SANDBOX=1` / `ADCP_COMPLY_CONTROLLER_UNGATED=1` env flag is +set, so silent fail-open misuse becomes loud without breaking the +optional-gate API shape. + +Also extends `TestControllerStore` with the five seed methods +(`seedProduct`, `seedPricingOption`, `seedCreative`, `seedPlan`, +`seedMediaBuy`) and exports `SEED_SCENARIOS`, `SeedScenario`, +`SeedFixtureCache`, and `createSeedFixtureCache`. Existing +`registerTestController` callers now pick up the seed surface and an +internal idempotency cache for free. Closes #701. diff --git a/examples/comply-controller-seller.ts b/examples/comply-controller-seller.ts new file mode 100644 index 000000000..62add7c19 --- /dev/null +++ b/examples/comply-controller-seller.ts @@ -0,0 +1,140 @@ +/** + * Example: Seller with `comply_test_controller` wired via `createComplyController`. + * + * A minimal non-guaranteed seller that implements enough of the controller + * surface to drive the `media_buy_seller` compliance storyboard: seed fixtures + * so storyboards can reference stable product / creative IDs, and force a + * creative to a target status so the approval flow can be exercised. + * + * Run with: + * + * npx tsx examples/comply-controller-seller.ts + * + * Then drive the controller from another terminal: + * + * # List advertised scenarios (force_/simulate_ only — seeds are universal) + * npx @adcp/client call http://localhost:3456/mcp comply_test_controller \ + * --scenario list_scenarios + * + * # Seed a product so storyboards can reference `test-product` by id + * npx @adcp/client call http://localhost:3456/mcp comply_test_controller \ + * --scenario seed_product \ + * --params '{"product_id":"test-product","fixture":{"delivery_type":"non_guaranteed"}}' + * + * # Force a creative into `rejected` to drive the rejection branch + * npx @adcp/client call http://localhost:3456/mcp comply_test_controller \ + * --scenario force_creative_status \ + * --params '{"creative_id":"cr-1","status":"rejected","rejection_reason":"Brand safety"}' + */ + +import { createTaskCapableServer, serve, TestControllerError } from '@adcp/client'; +import { createComplyController } from '@adcp/client/testing'; +import type { CreativeStatus } from '@adcp/client'; + +// --------------------------------------------------------------------------- +// In-memory state. In a real seller this would be Postgres / Firestore / +// whatever you already have — the controller just calls your adapters. +// --------------------------------------------------------------------------- + +interface ProductFixture { + delivery_type?: string; + channels?: string[]; + [key: string]: unknown; +} + +interface CreativeRecord { + id: string; + status: CreativeStatus; + rejection_reason?: string; + [key: string]: unknown; +} + +const products = new Map(); +const creatives = new Map(); + +// DEMO POLICY — do not copy this table into a production state machine. +// It is permissive enough to run the example storyboards; a real seller +// encodes their approval workflow here (and in production the controller +// itself is never registered). The point of this table is to show WHERE +// transition enforcement lives — the controller routes through the same +// guard as the production path. +const CREATIVE_TRANSITIONS: Record = { + processing: ['pending_review', 'approved', 'rejected'], + pending_review: ['approved', 'rejected'], + approved: ['rejected', 'archived'], + rejected: ['approved', 'archived'], + archived: [], +}; + +function assertCreativeTransition(from: CreativeStatus, to: CreativeStatus): void { + const allowed = CREATIVE_TRANSITIONS[from] ?? []; + if (!allowed.includes(to)) { + throw new TestControllerError('INVALID_TRANSITION', `Creative cannot move from ${from} to ${to}`, from); + } +} + +// --------------------------------------------------------------------------- +// Controller wiring +// +// `createComplyController` is cheap to build and stateless across requests +// aside from the seed idempotency cache. Building it once at module load +// lets every request reuse the same cache, which is exactly what we want +// for deterministic storyboard replay. +// --------------------------------------------------------------------------- + +const controller = createComplyController({ + // SECURITY: the sandbox gate MUST NOT trust caller-supplied fields. `ext`, + // `context`, and `params` all flow from the client — anyone can set + // `ext.sandbox = true`. Gate on something the server controls: an env + // variable, a tenant flag resolved from your auth layer, or a TLS SNI + // match. Production deployments should NOT register the controller at all. + sandboxGate: () => process.env.ADCP_SANDBOX === '1', + + seed: { + product: ({ product_id, fixture }, ctx) => { + // Use ctx.input.context.session_id to scope fixtures by session if needed. + void ctx; + products.set(product_id, fixture); + }, + creative: ({ creative_id, fixture }) => { + const existing = creatives.get(creative_id); + creatives.set(creative_id, { + id: creative_id, + status: (fixture.status as CreativeStatus) ?? existing?.status ?? 'pending_review', + ...fixture, + }); + }, + }, + + force: { + creative_status: ({ creative_id, status, rejection_reason }) => { + const record = creatives.get(creative_id); + if (!record) { + throw new TestControllerError('NOT_FOUND', `Creative ${creative_id} not found`); + } + assertCreativeTransition(record.status, status); + const previous = record.status; + record.status = status; + if (rejection_reason) record.rejection_reason = rejection_reason; + return { success: true, previous_state: previous, current_state: status }; + }, + }, +}); + +// --------------------------------------------------------------------------- +// Serve the controller behind an AdCP MCP server. +// +// `serve()` calls the factory once per incoming connection — build a fresh +// server each time but register the same controller so its idempotency +// cache persists across connections. +// --------------------------------------------------------------------------- + +function createAgentServer() { + const server = createTaskCapableServer({ name: 'example-comply-seller', version: '0.1.0' }); + // Register all production tools here (get_products, create_media_buy, …). + // Omitted for brevity — this example focuses on the controller surface. + controller.register(server); + return server; +} + +serve(createAgentServer, { port: 3456 }); diff --git a/src/lib/index.ts b/src/lib/index.ts index 0c6c0f8cb..957f9b327 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -504,8 +504,10 @@ export { toMcpResponse, TOOL_INPUT_SHAPE, CONTROLLER_SCENARIOS, + SEED_SCENARIOS, SESSION_ENTRY_CAP, enforceMapCap, + createSeedFixtureCache, PostgresTaskStore, cleanupExpiredTasks, getMcpTasksMigration, @@ -554,6 +556,8 @@ export type { TestControllerStoreFactory, TestControllerStoreOrFactory, ControllerScenario, + SeedScenario, + SeedFixtureCache, AdcpServerConfig, AdcpToolMap, AdcpServerToolName, diff --git a/src/lib/server/index.ts b/src/lib/server/index.ts index c81b7526f..7789b5925 100644 --- a/src/lib/server/index.ts +++ b/src/lib/server/index.ts @@ -62,14 +62,18 @@ export { toMcpResponse, TOOL_INPUT_SHAPE, CONTROLLER_SCENARIOS, + SEED_SCENARIOS, SESSION_ENTRY_CAP, enforceMapCap, + createSeedFixtureCache, } from './test-controller'; export type { TestControllerStore, TestControllerStoreFactory, TestControllerStoreOrFactory, ControllerScenario, + SeedScenario, + SeedFixtureCache, } from './test-controller'; export { serve } from './serve'; diff --git a/src/lib/server/test-controller.ts b/src/lib/server/test-controller.ts index 65dff09f9..17b0f862d 100644 --- a/src/lib/server/test-controller.ts +++ b/src/lib/server/test-controller.ts @@ -1,6 +1,13 @@ /** * Server-side comply_test_controller implementation. * + * Most new code should use {@link createComplyController} from + * `@adcp/client/testing` — it wraps this module with a domain-grouped + * (`seed` / `force` / `simulate`) adapter surface, typed params, sandbox + * gating, and built-in seed idempotency. The functions below remain + * supported for existing integrations and for custom wrappers that need + * access to the underlying request handler / MCP envelope helpers. + * * Sellers typically call `registerTestController(server, store)` to add the * `comply_test_controller` tool to their MCP server. The TestControllerStore * interface lets sellers wire up their own state management while the SDK @@ -102,16 +109,19 @@ import { toStructuredContent } from './responses'; // Scenario names // ──────────────────────────────────────────────────────────── -/** Scenario names the controller can support (derived from generated schema). */ +/** Scenario names advertised via `list_scenarios` (force_* and simulate_*). + * Seed scenarios are NOT advertised — the spec treats them as universal + * request-time capabilities, not a discoverable subset. */ export type ControllerScenario = ListScenariosSuccess['scenarios'][number]; +/** Scenario names accepted in `scenario` requests but not advertised via + * `list_scenarios`. Sellers opt in by implementing the matching store method. */ +export type SeedScenario = 'seed_product' | 'seed_pricing_option' | 'seed_creative' | 'seed_plan' | 'seed_media_buy'; + /** - * Scenario name constants. Use in place of string literals for type-safe - * dispatch in custom wrappers, tests, and factory `scenarios` declarations. - * - * Adding a scenario to the generated `ListScenariosSuccess` schema requires - * adding a matching entry here — the `ExhaustiveScenarioCheck` type below - * breaks the build if this map falls out of sync with the protocol. + * Scenario name constants for force_* and simulate_* (the advertised set). + * Use in place of string literals for type-safe dispatch in custom wrappers, + * tests, and factory `scenarios` declarations. * * ```ts * if (input.scenario === CONTROLLER_SCENARIOS.FORCE_ACCOUNT_STATUS) { ... } @@ -126,6 +136,19 @@ export const CONTROLLER_SCENARIOS = { SIMULATE_BUDGET_SPEND: 'simulate_budget_spend', } as const satisfies Record; +/** + * Seed scenario name constants. Used by seed dispatch and the + * {@link createComplyController} domain-grouped façade. Seeds are not + * advertised via `list_scenarios`. + */ +export const SEED_SCENARIOS = { + SEED_PRODUCT: 'seed_product', + SEED_PRICING_OPTION: 'seed_pricing_option', + SEED_CREATIVE: 'seed_creative', + SEED_PLAN: 'seed_plan', + SEED_MEDIA_BUY: 'seed_media_buy', +} as const satisfies Record; + /** * Build-time check: every scenario in the generated union must appear in * {@link CONTROLLER_SCENARIOS}. When the protocol adds a new scenario and @@ -191,6 +214,25 @@ export interface TestControllerStore { media_buy_id?: string; spend_percentage: number; }): Promise; + + /** Seed a product fixture. Returns when the fixture is persisted. */ + seedProduct?(productId: string, fixture: Record | undefined): Promise; + + /** Seed a pricing option fixture (scoped to a product). */ + seedPricingOption?( + productId: string, + pricingOptionId: string, + fixture: Record | undefined + ): Promise; + + /** Seed a creative fixture. */ + seedCreative?(creativeId: string, fixture: Record | undefined): Promise; + + /** Seed a plan fixture. */ + seedPlan?(planId: string, fixture: Record | undefined): Promise; + + /** Seed a media-buy fixture. */ + seedMediaBuy?(mediaBuyId: string, fixture: Record | undefined): Promise; } /** @@ -341,6 +383,202 @@ function controllerError( }; } +// ──────────────────────────────────────────────────────────── +// Seed fixture idempotency +// ──────────────────────────────────────────────────────────── + +/** + * Per-controller cache for seed-fixture equivalence checks. The handler uses + * it to enforce the spec-level rule that re-seeding with the same ID and an + * equivalent fixture yields `previous_state: "existing"`, while a divergent + * fixture returns `INVALID_PARAMS`. + * + * Passed explicitly to {@link handleTestControllerRequest} so custom wrappers + * can scope the cache to a session, tenant, or test run. Keys are + * scenario-scoped (`seed_creative:cr-1`) to avoid cross-kind collisions. + */ +export interface SeedFixtureCache { + get(key: string): Record | undefined; + set(key: string, fixture: Record): void; + has(key: string): boolean; +} + +/** Build an in-memory {@link SeedFixtureCache}. Capped at {@link SESSION_ENTRY_CAP} + * net-new keys to bound memory from a sandbox-authed caller that keeps seeding + * fresh IDs. Existing keys are always writable so re-seeds continue to work at + * the cap. Raise the cap explicitly for high-volume test runs. */ +export function createSeedFixtureCache(cap: number = SESSION_ENTRY_CAP): SeedFixtureCache { + const map = new Map>(); + return { + get: k => map.get(k), + set: (k, v) => { + if (!map.has(k) && map.size >= cap) { + throw new TestControllerError( + 'INVALID_STATE', + `Seed fixture cache full (limit ${cap}). Clear the session or reuse an existing id.` + ); + } + map.set(k, v); + }, + has: k => map.has(k), + }; +} + +/** Canonical JSON for equivalence checks. Sorts object keys recursively so + * `{a:1,b:2}` matches `{b:2,a:1}`. Arrays remain order-sensitive. Uses a + * seen-set to short-circuit circular references rather than stack-overflowing. */ +function canonicalJson(value: unknown, seen: WeakSet = new WeakSet()): string { + if (value === null || typeof value !== 'object') return JSON.stringify(value); + if (seen.has(value as object)) return '"__cycle__"'; + seen.add(value as object); + if (Array.isArray(value)) return `[${value.map(v => canonicalJson(v, seen)).join(',')}]`; + const entries = Object.keys(value as Record) + .sort() + .map(k => `${JSON.stringify(k)}:${canonicalJson((value as Record)[k], seen)}`); + return `{${entries.join(',')}}`; +} + +function fixturesEquivalent(a: Record, b: Record): boolean { + try { + return canonicalJson(a) === canonicalJson(b); + } catch { + // BigInt, symbols, or other non-JSON-safe values — treat as divergent so + // the caller gets a clear INVALID_PARAMS instead of an INTERNAL_ERROR. + return false; + } +} + +/** Dispatch a seed scenario through the correct adapter method, honoring the + * spec idempotency rules when a {@link SeedFixtureCache} is provided. */ +async function dispatchSeed( + store: TestControllerStore, + scenario: SeedScenario, + params: Record | undefined, + cache: SeedFixtureCache | undefined +): Promise { + // Validate fixture is a plain object (not null, array, or primitive) before + // casting — the adapter signature promises Record. + const rawFixture = params?.fixture; + if ( + rawFixture !== undefined && + (typeof rawFixture !== 'object' || Array.isArray(rawFixture) || rawFixture === null) + ) { + return controllerError( + 'INVALID_PARAMS', + `${scenario} requires params.fixture to be an object (got ${Array.isArray(rawFixture) ? 'array' : typeof rawFixture})` + ); + } + const fixture = (rawFixture as Record | undefined) ?? {}; + // Reject keys that can pollute prototypes when adapters spread the fixture + // into plain objects. Checked only at the top level — nested uses + // `Object.hasOwn` or similar in the adapter's own data layer. + for (const key of Object.keys(fixture)) { + if (key === '__proto__' || key === 'constructor' || key === 'prototype') { + return controllerError( + 'INVALID_PARAMS', + `${scenario} fixture key ${JSON.stringify(key)} is reserved and rejected to prevent prototype pollution` + ); + } + } + + // Route to adapter + pick a cache key unique per (kind, id). + type SeedDispatch = { key: string; invoke: () => Promise }; + let dispatch: SeedDispatch | null = null; + let missingParam: string | null = null; + + switch (scenario) { + case SEED_SCENARIOS.SEED_PRODUCT: { + if (!params?.product_id) { + missingParam = 'seed_product requires params.product_id'; + break; + } + if (!store.seedProduct) return controllerError('UNKNOWN_SCENARIO', `Scenario not supported: ${scenario}`); + const productId = params.product_id as string; + dispatch = { + key: `seed_product:${productId}`, + invoke: () => store.seedProduct!(productId, fixture), + }; + break; + } + case SEED_SCENARIOS.SEED_PRICING_OPTION: { + if (!params?.product_id || !params?.pricing_option_id) { + missingParam = 'seed_pricing_option requires params.product_id and params.pricing_option_id'; + break; + } + if (!store.seedPricingOption) return controllerError('UNKNOWN_SCENARIO', `Scenario not supported: ${scenario}`); + const productId = params.product_id as string; + const pricingOptionId = params.pricing_option_id as string; + dispatch = { + key: `seed_pricing_option:${productId}:${pricingOptionId}`, + invoke: () => store.seedPricingOption!(productId, pricingOptionId, fixture), + }; + break; + } + case SEED_SCENARIOS.SEED_CREATIVE: { + if (!params?.creative_id) { + missingParam = 'seed_creative requires params.creative_id'; + break; + } + if (!store.seedCreative) return controllerError('UNKNOWN_SCENARIO', `Scenario not supported: ${scenario}`); + const creativeId = params.creative_id as string; + dispatch = { + key: `seed_creative:${creativeId}`, + invoke: () => store.seedCreative!(creativeId, fixture), + }; + break; + } + case SEED_SCENARIOS.SEED_PLAN: { + if (!params?.plan_id) { + missingParam = 'seed_plan requires params.plan_id'; + break; + } + if (!store.seedPlan) return controllerError('UNKNOWN_SCENARIO', `Scenario not supported: ${scenario}`); + const planId = params.plan_id as string; + dispatch = { + key: `seed_plan:${planId}`, + invoke: () => store.seedPlan!(planId, fixture), + }; + break; + } + case SEED_SCENARIOS.SEED_MEDIA_BUY: { + if (!params?.media_buy_id) { + missingParam = 'seed_media_buy requires params.media_buy_id'; + break; + } + if (!store.seedMediaBuy) return controllerError('UNKNOWN_SCENARIO', `Scenario not supported: ${scenario}`); + const mediaBuyId = params.media_buy_id as string; + dispatch = { + key: `seed_media_buy:${mediaBuyId}`, + invoke: () => store.seedMediaBuy!(mediaBuyId, fixture), + }; + break; + } + } + + if (missingParam) return controllerError('INVALID_PARAMS', missingParam); + if (!dispatch) return controllerError('UNKNOWN_SCENARIO', `Scenario not supported: ${scenario}`); + + // Spec idempotency: same ID + equivalent fixture = existing; divergent = INVALID_PARAMS. + // We also handle caches where has() can race with get() (TTL/LRU eviction) — + // a missing prior entry is treated as a fresh seed rather than a crash. + const prior = cache?.has(dispatch.key) ? cache.get(dispatch.key) : undefined; + if (prior !== undefined) { + if (!fixturesEquivalent(prior, fixture)) { + return controllerError( + 'INVALID_PARAMS', + `Fixture for ${sanitizeLabel(dispatch.key)} diverges from the previously seeded fixture. ` + + 'Seed replays must carry an equivalent fixture or choose a new id.' + ); + } + await dispatch.invoke(); + return { success: true, previous_state: 'existing', current_state: 'existing' }; + } + + await dispatch.invoke(); + cache?.set(dispatch.key, fixture); + return { success: true, previous_state: 'none', current_state: 'seeded' }; +} + /** * Handle a `comply_test_controller` request. Exported so custom MCP wrappers * can compose it with their own middleware (AsyncLocalStorage, sandbox gating, @@ -353,7 +591,8 @@ function controllerError( */ export async function handleTestControllerRequest( storeOrFactory: TestControllerStoreOrFactory, - input: Record + input: Record, + options?: { seedCache?: SeedFixtureCache } ): Promise { const scenario = input.scenario as string | undefined; if (!scenario) { @@ -494,6 +733,13 @@ export async function handleTestControllerRequest( }); } + case SEED_SCENARIOS.SEED_PRODUCT: + case SEED_SCENARIOS.SEED_PRICING_OPTION: + case SEED_SCENARIOS.SEED_CREATIVE: + case SEED_SCENARIOS.SEED_PLAN: + case SEED_SCENARIOS.SEED_MEDIA_BUY: + return await dispatchSeed(store, scenario as SeedScenario, params, options?.seedCache); + default: return controllerError('UNKNOWN_SCENARIO', 'Unrecognized scenario name'); } @@ -512,7 +758,12 @@ export async function handleTestControllerRequest( function summarize(data: ComplyTestControllerResponse): string { if (data.success === false) return `Controller error: ${data.error}`; if ('scenarios' in data) return `Supported scenarios: ${data.scenarios.join(', ')}`; - if ('previous_state' in data) return `Transitioned from ${data.previous_state} to ${data.current_state}`; + if ('previous_state' in data) { + if (data.previous_state === 'none' && data.current_state === 'seeded') return 'Fixture seeded'; + if (data.previous_state === 'existing' && data.current_state === 'existing') + return 'Fixture re-seeded (equivalent)'; + return `Transitioned from ${data.previous_state} to ${data.current_state}`; + } return `Simulation complete: ${JSON.stringify(data.simulated)}`; } @@ -583,15 +834,22 @@ export const TOOL_INPUT_SHAPE = { */ export function registerTestController( server: AdcpServer | McpServer, - storeOrFactory: TestControllerStoreOrFactory + storeOrFactory: TestControllerStoreOrFactory, + options?: { seedCache?: SeedFixtureCache } ): void { const mcp = getSdkServer(server as AdcpServer) ?? (server as McpServer); + // Per-registration cache so seed idempotency holds across all requests on + // this server instance. Callers can supply their own cache to scope by + // session or tenant. + const seedCache = options?.seedCache ?? createSeedFixtureCache(); mcp.tool( 'comply_test_controller', 'Triggers seller-side state transitions for compliance testing. Sandbox only.', TOOL_INPUT_SHAPE, async input => { - const response = await handleTestControllerRequest(storeOrFactory, input as Record); + const response = await handleTestControllerRequest(storeOrFactory, input as Record, { + seedCache, + }); return toMcpResponse(response); } ); diff --git a/src/lib/testing/comply-controller.ts b/src/lib/testing/comply-controller.ts new file mode 100644 index 000000000..7da407eca --- /dev/null +++ b/src/lib/testing/comply-controller.ts @@ -0,0 +1,419 @@ +/** + * Seller-side scaffold for the `comply_test_controller` tool. + * + * `createComplyController` turns a set of domain-grouped adapters into the + * pieces needed to register the tool on an MCP server: a tool definition, a + * raw handler, an MCP-envelope handler, and a one-call `register(server)`. + * + * The helper owns: + * - Dispatching `scenario` to the correct adapter + * - Param validation + typed error envelopes (`UNKNOWN_SCENARIO`, + * `INVALID_PARAMS`, `NOT_FOUND`, `INVALID_TRANSITION`, `FORBIDDEN`) + * - Seed re-seed idempotency (same id + equivalent fixture = + * `previous_state: "existing"`; divergent fixture = `INVALID_PARAMS`) + * - Optional sandbox gating at both `tools/list` and per-request level + * + * The helper does NOT own the state machine. Transition enforcement lives + * inside your adapters so production and compliance testing share one source + * of truth — throw `TestControllerError('INVALID_TRANSITION', …)` from the + * adapter when a transition is disallowed. + * + * For custom MCP wrappers that need AsyncLocalStorage, sandbox gating at the + * transport layer, or a session-backed store factory, compose + * `handleTestControllerRequest`, `toMcpResponse`, and `TOOL_INPUT_SHAPE` from + * `@adcp/client/server` directly — the flat-store surface documented there. + * + * @example + * ```ts + * import { createComplyController } from '@adcp/client/testing'; + * + * const controller = createComplyController({ + * sandboxGate: input => input.auth?.sandbox === true, + * seed: { + * product: (params) => productRepo.upsert(params.product_id, params.fixture), + * creative: (params) => creativeRepo.upsert(params.creative_id, params.fixture), + * }, + * force: { + * creative_status: (params) => creativeRepo.transition(params.creative_id, params.status), + * }, + * }); + * + * controller.register(server); + * ``` + */ + +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { + CONTROLLER_SCENARIOS, + TOOL_INPUT_SHAPE, + createSeedFixtureCache, + handleTestControllerRequest, + toMcpResponse, + type ControllerScenario, + type SeedFixtureCache, + type TestControllerStore, +} from '../server/test-controller'; +import { getSdkServer, type AdcpServer } from '../server/adcp-server'; +import type { + ComplyTestControllerResponse, + ControllerError, + SimulationSuccess, + StateTransitionSuccess, +} from '../types/tools.generated'; +import type { AccountStatus, CreativeStatus, MediaBuyStatus } from '../types/core.generated'; +import type { McpToolResponse } from '../server/responses'; + +// ──────────────────────────────────────────────────────────── +// Adapter param shapes +// ──────────────────────────────────────────────────────────── + +/** Common second argument every adapter receives: the raw tool input so + * adapters can read `context.session_id`, `ext`, or vendor-specific fields. */ +export interface ComplyControllerContext { + /** The tool input as received over the wire, pre-validation. */ + input: Record; +} + +/** Params for `seed_product`. `fixture` mirrors the persisted product shape + * (delivery_type, channels, pricing_options, …). Kept permissive — the spec + * lets storyboards declare only the fields each test needs. */ +export interface SeedProductParams { + product_id: string; + fixture: Record; +} + +export interface SeedPricingOptionParams { + product_id: string; + pricing_option_id: string; + fixture: Record; +} + +export interface SeedCreativeParams { + creative_id: string; + fixture: Record; +} + +export interface SeedPlanParams { + plan_id: string; + fixture: Record; +} + +export interface SeedMediaBuyParams { + media_buy_id: string; + fixture: Record; +} + +export interface ForceCreativeStatusParams { + creative_id: string; + status: CreativeStatus; + rejection_reason?: string; +} + +export interface ForceAccountStatusParams { + account_id: string; + status: AccountStatus; +} + +export interface ForceMediaBuyStatusParams { + media_buy_id: string; + status: MediaBuyStatus; + rejection_reason?: string; +} + +export interface ForceSessionStatusParams { + session_id: string; + status: 'complete' | 'terminated'; + termination_reason?: string; +} + +export interface SimulateDeliveryParams { + media_buy_id: string; + impressions?: number; + clicks?: number; + conversions?: number; + reported_spend?: { amount: number; currency: string }; +} + +export interface SimulateBudgetSpendParams { + account_id?: string; + media_buy_id?: string; + spend_percentage: number; +} + +// ──────────────────────────────────────────────────────────── +// Adapter function shapes +// ──────────────────────────────────────────────────────────── + +/** Seed adapters persist the fixture to the seller's data layer. Return + * value is ignored — the helper builds the `previous_state` / `current_state` + * envelope from its own idempotency cache. Throw {@link TestControllerError} + * for typed errors (`INVALID_PARAMS`, `FORBIDDEN`). */ +export type SeedAdapter

= (params: P, ctx: ComplyControllerContext) => Promise | void; + +/** Force adapters return a {@link StateTransitionSuccess}. Throw + * `TestControllerError('INVALID_TRANSITION', msg, currentState)` when the + * state machine disallows the transition. */ +export type ForceAdapter

= ( + params: P, + ctx: ComplyControllerContext +) => Promise | StateTransitionSuccess; + +/** Simulate adapters return a {@link SimulationSuccess}. */ +export type SimulateAdapter

= ( + params: P, + ctx: ComplyControllerContext +) => Promise | SimulationSuccess; + +// ──────────────────────────────────────────────────────────── +// Controller config +// ──────────────────────────────────────────────────────────── + +export interface ComplyControllerConfig { + /** Per-request gate. Return `false` to reject with `FORBIDDEN` — suitable + * for tenants flagged as production, accounts not marked sandbox, or + * missing sandbox headers. When omitted, every request is allowed. + * + * MCP tools/list visibility is controlled by *whether you call* + * {@link ComplyController.register}. Wrap registration with your own + * environment check if you need to hide the tool outside sandbox: + * + * ```ts + * if (process.env.ADCP_SANDBOX === '1') controller.register(server); + * ``` + * + * Called for every request; the helper does NOT invoke adapters when the + * gate returns false. Errors thrown from the gate are treated as denials + * so a broken gate fails closed. + */ + sandboxGate?: (input: Record) => boolean | Promise; + + /** Seed adapters. Each registered method advertises its scenario as + * implemented; omitted methods return `UNKNOWN_SCENARIO` when called. */ + seed?: { + product?: SeedAdapter; + pricing_option?: SeedAdapter; + creative?: SeedAdapter; + plan?: SeedAdapter; + media_buy?: SeedAdapter; + }; + + /** Force adapters (state transitions). */ + force?: { + creative_status?: ForceAdapter; + account_status?: ForceAdapter; + media_buy_status?: ForceAdapter; + session_status?: ForceAdapter; + }; + + /** Simulation adapters (synthetic delivery/budget data). */ + simulate?: { + delivery?: SimulateAdapter; + budget_spend?: SimulateAdapter; + }; + + /** Override the seed idempotency cache (e.g., to scope by tenant or + * persist across restarts). Defaults to an unbounded in-memory cache. */ + seedCache?: SeedFixtureCache; +} + +// ──────────────────────────────────────────────────────────── +// Controller result +// ──────────────────────────────────────────────────────────── + +export interface ComplyControllerToolDefinition { + name: 'comply_test_controller'; + description: string; + inputSchema: typeof TOOL_INPUT_SHAPE; +} + +export interface ComplyController { + /** MCP tool definition — pass to `server.tool(...)` manually, or use + * {@link ComplyController.register} to do it for you. */ + readonly toolDefinition: ComplyControllerToolDefinition; + + /** Protocol-level handler. Returns a {@link ComplyTestControllerResponse} + * without the MCP envelope — useful for A2A adaptation or custom transports. */ + handleRaw(input: Record): Promise; + + /** MCP-envelope handler. Wraps {@link ComplyController.handleRaw} with + * `content` + `structuredContent` + `isError`. */ + handle(input: Record): Promise; + + /** Register the tool on an `AdcpServer` or raw `McpServer`. Equivalent to + * calling `server.tool(name, description, inputSchema, handle)`. */ + register(server: AdcpServer | McpServer): void; +} + +// ──────────────────────────────────────────────────────────── +// Implementation +// ──────────────────────────────────────────────────────────── + +function controllerError(code: ControllerError['error'], detail: string): ControllerError { + return { success: false, error: code, error_detail: detail }; +} + +/** Build a {@link TestControllerStore} that delegates to the domain-grouped + * adapters. Only methods for registered adapters are set, so + * `handleTestControllerRequest` returns `UNKNOWN_SCENARIO` for the rest. */ +function buildStore(config: ComplyControllerConfig, ctx: ComplyControllerContext): TestControllerStore { + const store: TestControllerStore = {}; + const { seed, force, simulate } = config; + + if (seed?.product) { + store.seedProduct = async (productId, fixture) => { + await seed.product!({ product_id: productId, fixture: fixture ?? {} }, ctx); + }; + } + if (seed?.pricing_option) { + store.seedPricingOption = async (productId, pricingOptionId, fixture) => { + await seed.pricing_option!( + { product_id: productId, pricing_option_id: pricingOptionId, fixture: fixture ?? {} }, + ctx + ); + }; + } + if (seed?.creative) { + store.seedCreative = async (creativeId, fixture) => { + await seed.creative!({ creative_id: creativeId, fixture: fixture ?? {} }, ctx); + }; + } + if (seed?.plan) { + store.seedPlan = async (planId, fixture) => { + await seed.plan!({ plan_id: planId, fixture: fixture ?? {} }, ctx); + }; + } + if (seed?.media_buy) { + store.seedMediaBuy = async (mediaBuyId, fixture) => { + await seed.media_buy!({ media_buy_id: mediaBuyId, fixture: fixture ?? {} }, ctx); + }; + } + + if (force?.creative_status) { + store.forceCreativeStatus = (creativeId, status, rejection_reason) => + Promise.resolve(force.creative_status!({ creative_id: creativeId, status, rejection_reason }, ctx)); + } + if (force?.account_status) { + store.forceAccountStatus = (accountId, status) => + Promise.resolve(force.account_status!({ account_id: accountId, status }, ctx)); + } + if (force?.media_buy_status) { + store.forceMediaBuyStatus = (mediaBuyId, status, rejection_reason) => + Promise.resolve(force.media_buy_status!({ media_buy_id: mediaBuyId, status, rejection_reason }, ctx)); + } + if (force?.session_status) { + store.forceSessionStatus = (sessionId, status, termination_reason) => + Promise.resolve(force.session_status!({ session_id: sessionId, status, termination_reason }, ctx)); + } + + if (simulate?.delivery) { + store.simulateDelivery = (mediaBuyId, params) => + Promise.resolve(simulate.delivery!({ media_buy_id: mediaBuyId, ...params }, ctx)); + } + if (simulate?.budget_spend) { + store.simulateBudgetSpend = params => Promise.resolve(simulate.budget_spend!(params, ctx)); + } + + return store; +} + +/** The set of force_* / simulate_* scenarios a config advertises via + * `list_scenarios`. Seeds are intentionally NOT advertised per the spec. */ +function advertisedScenarios(config: ComplyControllerConfig): ControllerScenario[] { + const out: ControllerScenario[] = []; + if (config.force?.creative_status) out.push(CONTROLLER_SCENARIOS.FORCE_CREATIVE_STATUS); + if (config.force?.account_status) out.push(CONTROLLER_SCENARIOS.FORCE_ACCOUNT_STATUS); + if (config.force?.media_buy_status) out.push(CONTROLLER_SCENARIOS.FORCE_MEDIA_BUY_STATUS); + if (config.force?.session_status) out.push(CONTROLLER_SCENARIOS.FORCE_SESSION_STATUS); + if (config.simulate?.delivery) out.push(CONTROLLER_SCENARIOS.SIMULATE_DELIVERY); + if (config.simulate?.budget_spend) out.push(CONTROLLER_SCENARIOS.SIMULATE_BUDGET_SPEND); + return out; +} + +/** Create a comply_test_controller scaffold from domain-grouped adapters. */ +export function createComplyController(config: ComplyControllerConfig): ComplyController { + const seedCache = config.seedCache ?? createSeedFixtureCache(); + const scenarios = advertisedScenarios(config); + // Stable reference so factory answers list_scenarios without invoking + // createStore — handleTestControllerRequest inspects the `scenarios` field. + const factoryScenarios = Object.freeze([...scenarios]) as readonly ControllerScenario[]; + + async function handleRaw(input: Record): Promise { + // `list_scenarios` is a capability probe — answer it without consulting + // the gate so buyer tooling can distinguish "controller exists but + // locked" from "controller missing entirely". State-mutating scenarios + // still go through the gate below. + const isListScenariosProbe = input.scenario === 'list_scenarios'; + + if (config.sandboxGate && !isListScenariosProbe) { + let allowed: unknown; + try { + allowed = await config.sandboxGate(input); + } catch (err) { + // Don't leak gate-internal errors. Treat as denial — matches how a + // sandbox would fail closed if its auth layer threw. + void err; + return controllerError('FORBIDDEN', 'Sandbox gate check failed'); + } + // Strict equality: anything that is not literally `true` is a denial. + // Guards against gates that accidentally return a reason string, a + // number, or a truthy object. + if (allowed !== true) { + return controllerError( + 'FORBIDDEN', + 'comply_test_controller is disabled in this environment (non-sandbox or gate denied)' + ); + } + } + + const ctx: ComplyControllerContext = { input }; + const store = buildStore(config, ctx); + + return handleTestControllerRequest({ scenarios: factoryScenarios, createStore: () => store }, input, { seedCache }); + } + + async function handle(input: Record) { + return toMcpResponse(await handleRaw(input)); + } + + // Shallow-copy the shape so a caller who mutates toolDefinition.inputSchema + // doesn't poison subsequent registrations. Zod schema values inside are + // themselves immutable, so a one-level copy is enough. + const toolDefinition: ComplyControllerToolDefinition = Object.freeze({ + name: 'comply_test_controller', + description: 'Triggers seller-side state transitions for compliance testing. Sandbox only.', + inputSchema: { ...TOOL_INPUT_SHAPE }, + }); + + // Per-controller latch so the "ungated controller" warning fires once even + // when serve() invokes the factory (and therefore register) per request. + let hasWarnedOnRegister = false; + + function register(server: AdcpServer | McpServer): void { + // Loud warning when the tool is about to be exposed without any explicit + // gate. Sellers who intentionally gate at the transport layer can silence + // this by setting ADCP_SANDBOX=1 (or ADCP_COMPLY_CONTROLLER_UNGATED=1 to + // opt out entirely). Prevents silent fail-open misuse without breaking + // the API's "gate is optional" shape. + if ( + !hasWarnedOnRegister && + !config.sandboxGate && + process.env.ADCP_SANDBOX !== '1' && + process.env.ADCP_COMPLY_CONTROLLER_UNGATED !== '1' + ) { + hasWarnedOnRegister = true; + // eslint-disable-next-line no-console + console.warn( + '[comply_test_controller] Registered with no sandboxGate and no ' + + 'ADCP_SANDBOX / ADCP_COMPLY_CONTROLLER_UNGATED env flag. The tool ' + + 'will accept every request — only correct if your transport layer ' + + 'enforces sandbox isolation. See createComplyController docs.' + ); + } + const mcp = getSdkServer(server as AdcpServer) ?? (server as McpServer); + mcp.tool(toolDefinition.name, toolDefinition.description, toolDefinition.inputSchema, async input => + handle(input as Record) + ); + } + + return { toolDefinition, handleRaw, handle, register }; +} diff --git a/src/lib/testing/index.ts b/src/lib/testing/index.ts index 5544e20af..ef1dbdfbb 100644 --- a/src/lib/testing/index.ts +++ b/src/lib/testing/index.ts @@ -122,6 +122,38 @@ export type { StubCallRecord } from './stubs'; export { expectControllerError, expectControllerSuccess } from './controller-assertions'; export type { ControllerErrorWithDetail } from './controller-assertions'; +// Seller-side comply_test_controller scaffold +export { createComplyController } from './comply-controller'; +export type { + ComplyController, + ComplyControllerConfig, + ComplyControllerContext, + ComplyControllerToolDefinition, + ForceAccountStatusParams, + ForceAdapter, + ForceCreativeStatusParams, + ForceMediaBuyStatusParams, + ForceSessionStatusParams, + SeedAdapter, + SeedCreativeParams, + SeedMediaBuyParams, + SeedPlanParams, + SeedPricingOptionParams, + SeedProductParams, + SimulateAdapter, + SimulateBudgetSpendParams, + SimulateDeliveryParams, +} from './comply-controller'; +// Re-export TestControllerError, cache helpers, and scenario constants so +// everything needed to wire the controller is reachable from /testing. +export { + TestControllerError, + CONTROLLER_SCENARIOS, + SEED_SCENARIOS, + createSeedFixtureCache, +} from '../server/test-controller'; +export type { ControllerScenario, SeedFixtureCache, SeedScenario } from '../server/test-controller'; + // Storyboard-driven testing export { // Runner diff --git a/test/comply-controller.test.js b/test/comply-controller.test.js new file mode 100644 index 000000000..0dc1ff182 --- /dev/null +++ b/test/comply-controller.test.js @@ -0,0 +1,618 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert'); +const { createComplyController, TestControllerError, createSeedFixtureCache } = require('../dist/lib/testing'); + +describe('createComplyController — list_scenarios', () => { + it('advertises only the adapters that are registered (force/simulate, not seeds)', async () => { + const controller = createComplyController({ + seed: { product: () => {} }, + force: { + creative_status: () => ({ success: true, previous_state: 'pending', current_state: 'approved' }), + }, + simulate: { + delivery: () => ({ success: true, simulated: {} }), + }, + }); + const result = await controller.handleRaw({ scenario: 'list_scenarios' }); + assert.strictEqual(result.success, true); + assert.deepStrictEqual([...result.scenarios].sort(), ['force_creative_status', 'simulate_delivery']); + }); + + it('returns empty scenarios when only seeds are configured (seeds are not advertised)', async () => { + const controller = createComplyController({ seed: { product: () => {} } }); + const result = await controller.handleRaw({ scenario: 'list_scenarios' }); + assert.strictEqual(result.success, true); + assert.deepStrictEqual(result.scenarios, []); + }); +}); + +describe('createComplyController — dispatch', () => { + it('routes force.creative_status to the adapter with typed params', async () => { + let captured; + const controller = createComplyController({ + force: { + creative_status: params => { + captured = params; + return { success: true, previous_state: 'pending_review', current_state: params.status }; + }, + }, + }); + const result = await controller.handleRaw({ + scenario: 'force_creative_status', + params: { creative_id: 'cr-1', status: 'rejected', rejection_reason: 'Brand safety' }, + }); + assert.strictEqual(result.success, true); + assert.strictEqual(result.current_state, 'rejected'); + assert.deepStrictEqual(captured, { + creative_id: 'cr-1', + status: 'rejected', + rejection_reason: 'Brand safety', + }); + }); + + it('returns UNKNOWN_SCENARIO when an adapter is not registered', async () => { + const controller = createComplyController({ + force: { creative_status: () => ({ success: true, previous_state: 'p', current_state: 'q' }) }, + }); + const result = await controller.handleRaw({ + scenario: 'force_account_status', + params: { account_id: 'acct-1', status: 'active' }, + }); + assert.strictEqual(result.success, false); + assert.strictEqual(result.error, 'UNKNOWN_SCENARIO'); + }); + + it('surfaces TestControllerError thrown from adapter as typed response', async () => { + const controller = createComplyController({ + force: { + media_buy_status: () => { + throw new TestControllerError('INVALID_TRANSITION', 'Cannot pause completed buy', 'completed'); + }, + }, + }); + const result = await controller.handleRaw({ + scenario: 'force_media_buy_status', + params: { media_buy_id: 'mb-1', status: 'paused' }, + }); + assert.strictEqual(result.success, false); + assert.strictEqual(result.error, 'INVALID_TRANSITION'); + assert.strictEqual(result.current_state, 'completed'); + }); + + it('accepts sync adapter return values', async () => { + const controller = createComplyController({ + simulate: { + budget_spend: params => ({ + success: true, + simulated: { spend_percentage: params.spend_percentage }, + }), + }, + }); + const result = await controller.handleRaw({ + scenario: 'simulate_budget_spend', + params: { media_buy_id: 'mb-1', spend_percentage: 95 }, + }); + assert.strictEqual(result.success, true); + assert.strictEqual(result.simulated.spend_percentage, 95); + }); + + it('passes the raw input to adapters via ctx', async () => { + let capturedCtx; + const controller = createComplyController({ + force: { + account_status: (_, ctx) => { + capturedCtx = ctx; + return { success: true, previous_state: 'active', current_state: 'suspended' }; + }, + }, + }); + const input = { + scenario: 'force_account_status', + params: { account_id: 'acct-1', status: 'suspended' }, + context: { session_id: 'sess-7' }, + }; + await controller.handleRaw(input); + assert.strictEqual(capturedCtx.input.context.session_id, 'sess-7'); + }); +}); + +describe('createComplyController — seed idempotency', () => { + it('seeds a fresh fixture and returns previous_state: "none"', async () => { + const persisted = new Map(); + const controller = createComplyController({ + seed: { + product: params => { + persisted.set(params.product_id, params.fixture); + }, + }, + }); + const result = await controller.handleRaw({ + scenario: 'seed_product', + params: { product_id: 'p1', fixture: { delivery_type: 'non_guaranteed' } }, + }); + assert.strictEqual(result.success, true); + assert.strictEqual(result.previous_state, 'none'); + assert.strictEqual(result.current_state, 'seeded'); + assert.ok(persisted.has('p1')); + }); + + it('re-seeds with equivalent fixture returns previous_state: "existing" and still invokes adapter', async () => { + let adapterCalls = 0; + const controller = createComplyController({ + seed: { + product: () => { + adapterCalls++; + }, + }, + }); + const first = await controller.handleRaw({ + scenario: 'seed_product', + params: { product_id: 'p1', fixture: { delivery_type: 'non_guaranteed', channels: ['display'] } }, + }); + assert.strictEqual(first.previous_state, 'none'); + // Reordered keys — canonical JSON should treat this as equivalent. + const second = await controller.handleRaw({ + scenario: 'seed_product', + params: { product_id: 'p1', fixture: { channels: ['display'], delivery_type: 'non_guaranteed' } }, + }); + assert.strictEqual(second.success, true); + assert.strictEqual(second.previous_state, 'existing'); + assert.strictEqual(second.current_state, 'existing'); + assert.strictEqual(adapterCalls, 2, 'adapter invoked on each call so its storage stays idempotent'); + }); + + it('re-seeds with divergent fixture returns INVALID_PARAMS without invoking adapter', async () => { + let adapterCalls = 0; + const controller = createComplyController({ + seed: { + creative: () => { + adapterCalls++; + }, + }, + }); + await controller.handleRaw({ + scenario: 'seed_creative', + params: { creative_id: 'cr-1', fixture: { status: 'approved' } }, + }); + const second = await controller.handleRaw({ + scenario: 'seed_creative', + params: { creative_id: 'cr-1', fixture: { status: 'rejected' } }, + }); + assert.strictEqual(second.success, false); + assert.strictEqual(second.error, 'INVALID_PARAMS'); + assert.match(second.error_detail, /diverges/); + assert.strictEqual(adapterCalls, 1, 'divergent fixture must not hit the adapter'); + }); + + it('distinguishes cache keys across scenario kinds so id collisions do not false-match', async () => { + const controller = createComplyController({ + seed: { + product: () => {}, + creative: () => {}, + }, + }); + await controller.handleRaw({ + scenario: 'seed_product', + params: { product_id: 'same-id', fixture: { delivery_type: 'guaranteed' } }, + }); + const creativeResult = await controller.handleRaw({ + scenario: 'seed_creative', + params: { creative_id: 'same-id', fixture: { status: 'approved' } }, + }); + assert.strictEqual(creativeResult.success, true); + assert.strictEqual(creativeResult.previous_state, 'none', 'creative with same id must be fresh'); + }); + + it('honors a caller-supplied seedCache for external scoping', async () => { + const cache = createSeedFixtureCache(); + const controllerA = createComplyController({ + seed: { product: () => {} }, + seedCache: cache, + }); + const controllerB = createComplyController({ + seed: { product: () => {} }, + seedCache: cache, + }); + await controllerA.handleRaw({ + scenario: 'seed_product', + params: { product_id: 'p1', fixture: { v: 1 } }, + }); + // Second controller sharing the same cache sees 'p1' as existing. + const result = await controllerB.handleRaw({ + scenario: 'seed_product', + params: { product_id: 'p1', fixture: { v: 1 } }, + }); + assert.strictEqual(result.previous_state, 'existing'); + }); + + it('returns INVALID_PARAMS when a seed is missing its required id', async () => { + const controller = createComplyController({ seed: { product: () => {} } }); + const result = await controller.handleRaw({ + scenario: 'seed_product', + params: { fixture: { delivery_type: 'guaranteed' } }, + }); + assert.strictEqual(result.error, 'INVALID_PARAMS'); + assert.match(result.error_detail, /product_id/); + }); + + it('returns UNKNOWN_SCENARIO when a seed adapter is not registered', async () => { + const controller = createComplyController({}); + const result = await controller.handleRaw({ + scenario: 'seed_creative', + params: { creative_id: 'cr-1', fixture: {} }, + }); + assert.strictEqual(result.success, false); + assert.strictEqual(result.error, 'UNKNOWN_SCENARIO'); + }); + + it('returns INVALID_PARAMS when fixture is not a plain object', async () => { + const controller = createComplyController({ seed: { product: () => {} } }); + for (const bad of ['string-fixture', 42, true, ['array']]) { + const result = await controller.handleRaw({ + scenario: 'seed_product', + params: { product_id: 'p1', fixture: bad }, + }); + assert.strictEqual(result.success, false, `fixture=${JSON.stringify(bad)} should be rejected`); + assert.strictEqual(result.error, 'INVALID_PARAMS'); + assert.match(result.error_detail, /fixture/); + } + }); + + it('accepts an omitted fixture (defaults to empty object) and still tracks idempotency', async () => { + const controller = createComplyController({ seed: { product: () => {} } }); + const first = await controller.handleRaw({ + scenario: 'seed_product', + params: { product_id: 'p1' }, + }); + assert.strictEqual(first.previous_state, 'none'); + const second = await controller.handleRaw({ + scenario: 'seed_product', + params: { product_id: 'p1', fixture: {} }, + }); + assert.strictEqual(second.previous_state, 'existing', 'omitted fixture and {} should be equivalent'); + }); + + it('treats a cache that evicted between has() and get() as fresh', async () => { + // Simulate a TTL cache: has() true, get() undefined. The handler should + // recover rather than crash or mis-classify. + let hasCalled = false; + const flakyCache = { + has: () => { + hasCalled = true; + return true; + }, + get: () => undefined, + set: () => {}, + }; + const controller = createComplyController({ + seed: { product: () => {} }, + seedCache: flakyCache, + }); + const result = await controller.handleRaw({ + scenario: 'seed_product', + params: { product_id: 'p1', fixture: { v: 1 } }, + }); + assert.ok(hasCalled); + assert.strictEqual(result.success, true); + assert.strictEqual(result.previous_state, 'none', 'evicted entry should seed fresh, not crash'); + }); +}); + +describe('createComplyController — fixture hardening', () => { + it('rejects fixtures with __proto__ keys to prevent prototype pollution', async () => { + const controller = createComplyController({ seed: { product: () => {} } }); + const result = await controller.handleRaw({ + scenario: 'seed_product', + params: { product_id: 'p1', fixture: { ['__proto__']: { polluted: true } } }, + }); + assert.strictEqual(result.success, false); + assert.strictEqual(result.error, 'INVALID_PARAMS'); + assert.match(result.error_detail, /prototype pollution/); + }); + + it('rejects fixtures with constructor/prototype keys', async () => { + const controller = createComplyController({ seed: { product: () => {} } }); + for (const key of ['constructor', 'prototype']) { + const result = await controller.handleRaw({ + scenario: 'seed_product', + params: { product_id: 'p1', fixture: { [key]: 'bad' } }, + }); + assert.strictEqual(result.error, 'INVALID_PARAMS', `key=${key} must be rejected`); + } + }); +}); + +describe('createComplyController — seed cache cap', () => { + it('returns INVALID_STATE when the cache exceeds its cap', async () => { + const { createSeedFixtureCache } = require('../dist/lib/testing'); + const cache = createSeedFixtureCache(2); + const controller = createComplyController({ + seed: { product: () => {} }, + seedCache: cache, + }); + // Fill the cache. + await controller.handleRaw({ + scenario: 'seed_product', + params: { product_id: 'p1', fixture: { v: 1 } }, + }); + await controller.handleRaw({ + scenario: 'seed_product', + params: { product_id: 'p2', fixture: { v: 2 } }, + }); + // Net-new key at cap should be rejected. + const rejected = await controller.handleRaw({ + scenario: 'seed_product', + params: { product_id: 'p3', fixture: { v: 3 } }, + }); + assert.strictEqual(rejected.success, false); + assert.strictEqual(rejected.error, 'INVALID_STATE'); + assert.match(rejected.error_detail, /limit 2/); + // Re-seeding an existing key must still work at the cap. + const replay = await controller.handleRaw({ + scenario: 'seed_product', + params: { product_id: 'p1', fixture: { v: 1 } }, + }); + assert.strictEqual(replay.success, true); + assert.strictEqual(replay.previous_state, 'existing'); + }); +}); + +describe('createComplyController — sandboxGate', () => { + it('allows requests when the gate returns true', async () => { + const controller = createComplyController({ + sandboxGate: input => input.context?.sandbox === true, + force: { + account_status: () => ({ success: true, previous_state: 'active', current_state: 'suspended' }), + }, + }); + const result = await controller.handleRaw({ + scenario: 'force_account_status', + params: { account_id: 'acct-1', status: 'suspended' }, + context: { sandbox: true }, + }); + assert.strictEqual(result.success, true); + }); + + it('rejects requests with FORBIDDEN when the gate returns false', async () => { + const controller = createComplyController({ + sandboxGate: () => false, + force: { + account_status: () => ({ success: true, previous_state: 'active', current_state: 'suspended' }), + }, + }); + const result = await controller.handleRaw({ + scenario: 'force_account_status', + params: { account_id: 'acct-1', status: 'suspended' }, + }); + assert.strictEqual(result.success, false); + assert.strictEqual(result.error, 'FORBIDDEN'); + }); + + it('lets list_scenarios bypass the gate so capability probes always work', async () => { + // A locked controller must still advertise its scenarios so buyer + // tooling can distinguish "controller exists but locked" from "no + // controller". State-mutating scenarios still get denied below. + let gateCalls = 0; + const controller = createComplyController({ + sandboxGate: () => { + gateCalls++; + return false; + }, + force: { account_status: () => ({ success: true, previous_state: 'a', current_state: 'b' }) }, + }); + const probe = await controller.handleRaw({ scenario: 'list_scenarios' }); + assert.strictEqual(probe.success, true); + assert.deepStrictEqual(probe.scenarios, ['force_account_status']); + assert.strictEqual(gateCalls, 0, 'gate must not fire on capability probe'); + + // State-mutating calls are still denied. + const denied = await controller.handleRaw({ + scenario: 'force_account_status', + params: { account_id: 'acct-1', status: 'suspended' }, + }); + assert.strictEqual(denied.error, 'FORBIDDEN'); + assert.strictEqual(gateCalls, 1); + }); + + it('fails closed when the gate throws', async () => { + const controller = createComplyController({ + sandboxGate: () => { + throw new Error('auth layer exploded'); + }, + force: { account_status: () => ({ success: true, previous_state: 'a', current_state: 'b' }) }, + }); + const result = await controller.handleRaw({ + scenario: 'force_account_status', + params: { account_id: 'acct-1', status: 'suspended' }, + }); + assert.strictEqual(result.success, false); + assert.strictEqual(result.error, 'FORBIDDEN'); + // Gate-internal errors must not leak. + assert.ok(!result.error_detail.includes('auth layer exploded')); + }); + + it('denies when the gate returns a truthy non-boolean (must be strictly true)', async () => { + const controller = createComplyController({ + // A gate author returning a "reason string" would otherwise bypass. + sandboxGate: () => 'allowed', + force: { account_status: () => ({ success: true, previous_state: 'a', current_state: 'b' }) }, + }); + const result = await controller.handleRaw({ + scenario: 'force_account_status', + params: { account_id: 'acct-1', status: 'suspended' }, + }); + assert.strictEqual(result.success, false); + assert.strictEqual(result.error, 'FORBIDDEN'); + }); + + it('does NOT invoke adapters when the gate denies', async () => { + let adapterCalls = 0; + const controller = createComplyController({ + sandboxGate: () => false, + seed: { + product: () => { + adapterCalls++; + }, + }, + }); + await controller.handleRaw({ + scenario: 'seed_product', + params: { product_id: 'p1', fixture: {} }, + }); + assert.strictEqual(adapterCalls, 0); + }); +}); + +describe('createComplyController — MCP envelope', () => { + it('handle() produces structuredContent and no isError on success', async () => { + const controller = createComplyController({ + force: { account_status: () => ({ success: true, previous_state: 'active', current_state: 'suspended' }) }, + }); + const env = await controller.handle({ + scenario: 'force_account_status', + params: { account_id: 'acct-1', status: 'suspended' }, + }); + assert.strictEqual(env.isError, undefined); + assert.strictEqual(env.structuredContent.current_state, 'suspended'); + }); + + it('handle() sets isError: true on ControllerError responses', async () => { + const controller = createComplyController({}); + const env = await controller.handle({ + scenario: 'force_account_status', + params: { account_id: 'acct-1', status: 'suspended' }, + }); + assert.strictEqual(env.isError, true); + assert.match(env.content[0].text, /UNKNOWN_SCENARIO/); + }); + + it('exposes a protocol-compliant toolDefinition', () => { + const controller = createComplyController({}); + assert.strictEqual(controller.toolDefinition.name, 'comply_test_controller'); + assert.match(controller.toolDefinition.description, /Sandbox only/); + assert.ok('scenario' in controller.toolDefinition.inputSchema); + assert.ok('params' in controller.toolDefinition.inputSchema); + }); + + it('returns an isolated inputSchema so controllers do not share mutable state', () => { + const a = createComplyController({}); + const b = createComplyController({}); + assert.notStrictEqual(a.toolDefinition.inputSchema, b.toolDefinition.inputSchema); + // toolDefinition itself is frozen against reassignment of the shape ref. + assert.ok(Object.isFrozen(a.toolDefinition)); + }); +}); + +describe('createComplyController — register()', () => { + const makeFakeServer = () => { + const calls = []; + return { + calls, + tool: (name, description, schema, handler) => { + calls.push({ name, description, schemaKeys: Object.keys(schema), handlerType: typeof handler }); + }, + }; + }; + + // Capture console.warn without polluting test output. + const withCapturedWarn = fn => { + const warnings = []; + const original = console.warn; + console.warn = (...args) => warnings.push(args.join(' ')); + try { + fn(warnings); + } finally { + console.warn = original; + } + }; + + it('registers the tool on a raw McpServer via server.tool()', () => { + const fake = makeFakeServer(); + const controller = createComplyController({ + sandboxGate: () => true, + force: { account_status: () => ({ success: true, previous_state: 'a', current_state: 'b' }) }, + }); + controller.register(fake); + assert.strictEqual(fake.calls.length, 1); + assert.strictEqual(fake.calls[0].name, 'comply_test_controller'); + assert.deepStrictEqual(fake.calls[0].schemaKeys.sort(), ['context', 'ext', 'params', 'scenario']); + assert.strictEqual(fake.calls[0].handlerType, 'function'); + }); + + it('warns on register() when no sandboxGate and no ADCP_SANDBOX env flag', () => { + const originalSandbox = process.env.ADCP_SANDBOX; + const originalUngated = process.env.ADCP_COMPLY_CONTROLLER_UNGATED; + delete process.env.ADCP_SANDBOX; + delete process.env.ADCP_COMPLY_CONTROLLER_UNGATED; + try { + withCapturedWarn(warnings => { + const controller = createComplyController({}); + controller.register(makeFakeServer()); + assert.strictEqual(warnings.length, 1); + assert.match(warnings[0], /no sandboxGate/i); + assert.match(warnings[0], /ADCP_SANDBOX/); + }); + } finally { + if (originalSandbox !== undefined) process.env.ADCP_SANDBOX = originalSandbox; + if (originalUngated !== undefined) process.env.ADCP_COMPLY_CONTROLLER_UNGATED = originalUngated; + } + }); + + it('stays silent on register() when sandboxGate is configured', () => { + withCapturedWarn(warnings => { + const controller = createComplyController({ sandboxGate: () => true }); + controller.register(makeFakeServer()); + assert.strictEqual(warnings.length, 0); + }); + }); + + it('warns once per controller even when register() is called per-request (serve() pattern)', () => { + const originalSandbox = process.env.ADCP_SANDBOX; + const originalUngated = process.env.ADCP_COMPLY_CONTROLLER_UNGATED; + delete process.env.ADCP_SANDBOX; + delete process.env.ADCP_COMPLY_CONTROLLER_UNGATED; + try { + withCapturedWarn(warnings => { + const controller = createComplyController({}); + // serve() invokes the factory per request → register fires repeatedly. + controller.register(makeFakeServer()); + controller.register(makeFakeServer()); + controller.register(makeFakeServer()); + assert.strictEqual(warnings.length, 1, 'warning must de-dupe per controller instance'); + }); + } finally { + if (originalSandbox !== undefined) process.env.ADCP_SANDBOX = originalSandbox; + if (originalUngated !== undefined) process.env.ADCP_COMPLY_CONTROLLER_UNGATED = originalUngated; + } + }); + + it('stays silent when ADCP_SANDBOX=1 is set (deployment-level gating opt-out)', () => { + const original = process.env.ADCP_SANDBOX; + process.env.ADCP_SANDBOX = '1'; + try { + withCapturedWarn(warnings => { + const controller = createComplyController({}); + controller.register(makeFakeServer()); + assert.strictEqual(warnings.length, 0); + }); + } finally { + if (original === undefined) delete process.env.ADCP_SANDBOX; + else process.env.ADCP_SANDBOX = original; + } + }); + + it('stays silent when ADCP_COMPLY_CONTROLLER_UNGATED=1 (explicit opt-out)', () => { + const original = process.env.ADCP_COMPLY_CONTROLLER_UNGATED; + process.env.ADCP_COMPLY_CONTROLLER_UNGATED = '1'; + try { + withCapturedWarn(warnings => { + const controller = createComplyController({}); + controller.register(makeFakeServer()); + assert.strictEqual(warnings.length, 0); + }); + } finally { + if (original === undefined) delete process.env.ADCP_COMPLY_CONTROLLER_UNGATED; + else process.env.ADCP_COMPLY_CONTROLLER_UNGATED = original; + } + }); +});