From 0ce6da121d315eef0801bdc70b61da65521c82ae Mon Sep 17 00:00:00 2001 From: Revinand Date: Thu, 3 Sep 2026 18:56:56 +0200 Subject: [PATCH 1/8] feat(core): add explicit backend input bindings --- docs/contract-surface.txt | 1 + docs/contracts.md | 1 + src/core/domain/resource.ts | 17 ++ src/core/execution/backend-http.ts | 214 ++++++++++-------- .../unit/core/execution/backend-http.test.ts | 175 ++++++++++++++ 5 files changed, 317 insertions(+), 91 deletions(-) diff --git a/docs/contract-surface.txt b/docs/contract-surface.txt index 325d86a..5edeccd 100644 --- a/docs/contract-surface.txt +++ b/docs/contract-surface.txt @@ -31,6 +31,7 @@ interface BackendExecutor { interface BackendHandler { readonly headers?: Readonly>; + readonly inputBindings?: { readonly path?: string; readonly query?: string; readonly body?: string; }; readonly method: BackendMethod; readonly timeoutMs?: number; readonly type: "http"; diff --git a/docs/contracts.md b/docs/contracts.md index fe30ea3..cbf4d52 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -75,6 +75,7 @@ the generated file is right and this table is stale. - **Additive:** `ProtocolName` gains `'a2a'`; config gains `protocols.a2a` (disabled by default, mount `/a2a`) and accepts `expose: [a2a]`. - **Additive:** `AdapterHttpRoute` and the optional `HttpProtocolAdapter.additionalHttpRoutes`. A protocol whose specification pins a discovery URL outside the adapter's mount (A2A's `/.well-known/agent-card.json`) declares it instead of the gateway growing a per-protocol route conditional. Fixed routes get the mount's guarantees — unconsumed body, concurrency cap, failure isolation — and two adapters claiming one path is rejected before either starts. - **Removed from the wire:** `/.well-known/agent-commerce` no longer publishes `payments.x402.facilitator.url`. A facilitator endpoint can carry a tenant path or an API key, exactly like `rpcUrl`, which the same route already withholds. It gained `payments.x402.mode` (`local` | `testnet` | `mainnet`) instead — chain id 84532 belongs to both the local dev chain and public Base Sepolia, so the network id alone cannot say which one a client is talking to. +- **Additive:** optional `BackendHandler.inputBindings` (`{ path?, query?, body? }`), naming the top-level input properties that carry each part of the backend request. *Use case:* `POST /users/{userId}/orders?notify=true` with a JSON body — path, query and body at once — which the leftover rule cannot express, because on a body-capable method everything not consumed by the URL template becomes the body. *Alternative considered:* infer the split from the input schema's property names; rejected, since the shape a merchant's backend expects is operator configuration, not something to guess from a schema, and guessing wrong on a paid resource is payment-without-delivery. *Compatibility:* absent means the legacy mapping, byte-for-byte; no consumer changes. When present, only named groups are forwarded — unmapped top-level input never reaches the backend. `validateBackendRequestShape` resolves both modes through the same function, so every shape error (missing/invalid path parameter, non-object group, query collision with the configured URL) is still an `INPUT_INVALID` raised before pricing. --- # Integration contract — exact factory signatures diff --git a/src/core/domain/resource.ts b/src/core/domain/resource.ts index 2b3c231..ab5008d 100644 --- a/src/core/domain/resource.ts +++ b/src/core/domain/resource.ts @@ -23,6 +23,23 @@ export interface BackendHandler { readonly headers?: Readonly>; /** Hard upper bound on the backend call. Defaults to DEFAULT_BACKEND_TIMEOUT_MS. */ readonly timeoutMs?: number; + /** + * Names the top-level input properties carrying each part of the request. + * + * Absent (the default) keeps the original mapping: `{param}` values are read + * from top-level input and everything left over becomes either the query + * string (GET/DELETE) or the entire JSON body (POST/PUT/PATCH). That mapping + * cannot express `POST /users/{userId}/orders?notify=true` with a JSON body + * — one perfectly ordinary REST operation with all three parts at once. + * + * When present, each group is sourced independently and top-level input that + * no binding names is not forwarded to the backend at all. + */ + readonly inputBindings?: { + readonly path?: string; + readonly query?: string; + readonly body?: string; + }; } /** Default backend timeout when a resource does not specify one. */ diff --git a/src/core/execution/backend-http.ts b/src/core/execution/backend-http.ts index b2b5a82..0359935 100644 --- a/src/core/execution/backend-http.ts +++ b/src/core/execution/backend-http.ts @@ -6,9 +6,15 @@ * followed (SSRF hardening). * - `{param}` segments in `handler.url` are filled from validated input and * URL-encoded; whatever remains goes to the query string (GET/DELETE) or a - * JSON body (POST/PUT/PATCH). + * JSON body (POST/PUT/PATCH). `handler.inputBindings` replaces that + * leftover rule with one that names each group explicitly — see + * `buildBackendRequestParts`. */ -import { type BackendHandler, DEFAULT_BACKEND_TIMEOUT_MS } from '../domain/resource.js'; +import { + type BackendHandler, + type BackendMethod, + DEFAULT_BACKEND_TIMEOUT_MS, +} from '../domain/resource.js'; import { CommerceError } from '../errors/index.js'; import type { BackendExecutor, BackendRequest, BackendResponse } from '../interfaces/backend.js'; import { type Logger, NOOP_LOGGER } from '../interfaces/logger.js'; @@ -51,36 +57,19 @@ export class HttpBackendExecutor implements BackendExecutor { const timeoutMs = handler.timeoutMs ?? DEFAULT_BACKEND_TIMEOUT_MS; const inputRecord = isPlainObject(request.input) ? request.input : {}; + const context = { requestId: request.requestId, resourceId: request.resourceId }; + // Throws INPUT_INVALID for every shape problem. The pipeline already ran + // the same call pre-payment through validateBackendRequestShape(); this is + // defence in depth for a caller that bypasses it, not the normal path. + const parts = buildBackendRequestParts(handler, inputRecord, context); + let target: URL; - let remaining: Record; try { - const templated = applyPathTemplate(handler.url, inputRecord); - target = new URL(templated.url); - remaining = templated.remaining; + target = new URL(parts.url); } catch (error) { - if (error instanceof Error && error.message.startsWith('invalid-path-parameter:')) { - const field = error.message.slice('invalid-path-parameter:'.length); - throw new CommerceError('INPUT_INVALID', `Path parameter "${field}" is not a valid value`, { - requestId: request.requestId, - resourceId: request.resourceId, - details: { field }, - }); - } - // Mirrors validateBackendRequestShape()'s own copy - // — the pipeline calls that one first, so this branch is defence in - // depth for a caller that bypasses it, not the normal path. - if (error instanceof Error && error.message.startsWith('missing-path-parameter:')) { - const field = error.message.slice('missing-path-parameter:'.length); - throw new CommerceError('INPUT_INVALID', `Path parameter "${field}" was not supplied`, { - requestId: request.requestId, - resourceId: request.resourceId, - details: { field }, - }); - } throw new CommerceError('BACKEND_ERROR', 'Backend URL could not be constructed from input', { - requestId: request.requestId, - resourceId: request.resourceId, - details: { reason: describeConstructionError(error) }, + ...context, + details: { reason: 'invalid-url' }, cause: error, }); } @@ -111,21 +100,19 @@ export class HttpBackendExecutor implements BackendExecutor { const headers: Record = { ...(handler.headers ?? {}) }; let body: string | undefined; - if (handler.method === 'GET' || handler.method === 'DELETE') { - //.set() REPLACES an existing param, so without this check a caller - // input key with the same name as an operator-baked-in query param - // (?apikey=SECRET in handler.url) silently overwrites it. - // Shared with validateBackendRequestShape() — that copy runs - // *before* payment, this one is defence in depth. - checkQueryCollision(target, remaining, { - requestId: request.requestId, - resourceId: request.resourceId, - }); - for (const [key, value] of Object.entries(remaining)) { - target.searchParams.set(key, stringifyPrimitive(value)); - } - } else { - body = JSON.stringify(remaining); + //.set() REPLACES an existing param, so without this check a caller + // input key with the same name as an operator-baked-in query param + // (?apikey=SECRET in handler.url) silently overwrites it. + // Shared with validateBackendRequestShape() — that copy runs + // *before* payment, this one is defence in depth. + checkQueryCollision(target, parts.query, context); + for (const [key, value] of Object.entries(parts.query)) { + target.searchParams.set(key, stringifyPrimitive(value)); + } + if (parts.body !== undefined) { + body = JSON.stringify(parts.body.value); + // A configured Content-Type stays authoritative: a backend wanting + // `application/vnd.x+json` says so in config and we do not override it. if (!hasHeader(headers, 'content-type')) { headers['content-type'] = 'application/json'; } @@ -285,52 +272,90 @@ export function findUnparsedBraceToken(url: string): string | undefined { export function validateBackendRequestShape( handler: BackendHandler, input: unknown, - context: { readonly requestId: string; readonly resourceId: string }, + context: ShapeContext, ): void { const inputRecord = isPlainObject(input) ? input : {}; - let templated: { url: string; remaining: Record }; + // Every shape error — missing or invalid path parameter, a bound group that + // is not an object — throws INPUT_INVALID from here, before payment. + const parts = buildBackendRequestParts(handler, inputRecord, context); + + let target: URL; try { - templated = applyPathTemplate(handler.url, inputRecord); - } catch (error) { - if (error instanceof Error && error.message.startsWith('invalid-path-parameter:')) { - const field = error.message.slice('invalid-path-parameter:'.length); - throw new CommerceError('INPUT_INVALID', `Path parameter "${field}" is not a valid value`, { - requestId: context.requestId, - resourceId: context.resourceId, - details: { field }, - }); - } - if (error instanceof Error && error.message.startsWith('missing-path-parameter:')) { - // Returning here is tempting: a missing path parameter is a - // config/schema mismatch, not caller input, and so arguably not this - // function's job. But that lets a paid, `{param}`-templated resource - // whose schema can never supply it reach settle() on every call — the buyer pays, the backend is never - // called, and there is no refund. The request genuinely cannot be - // served, so it throws here, before payment, the same as an invalid - // value does two lines up. `normaliseResource` (src/config) is - // the root-cause fix — it rejects this shape at config load — but a - // hand-built `CommerceResource` or a future adapter must not be able - // to reintroduce the money bug just by skipping config validation. - const field = error.message.slice('missing-path-parameter:'.length); - throw new CommerceError('INPUT_INVALID', `Path parameter "${field}" was not supplied`, { - requestId: context.requestId, - resourceId: context.resourceId, - details: { field }, - }); - } - return; + target = new URL(parts.url); + } catch { + return; // call() surfaces the real BACKEND_ERROR for an unparseable URL. + } + checkQueryCollision(target, parts.query, context); +} + +type ShapeContext = { readonly requestId: string; readonly resourceId: string }; + +/** The path-templated URL plus the query and body values a request carries. */ +interface BackendRequestParts { + readonly url: string; + readonly query: Record; + /** Present when a JSON body should be sent; `value` is what gets encoded. */ + readonly body?: { readonly value: unknown }; +} + +function acceptsBody(method: BackendMethod): boolean { + return method !== 'GET' && method !== 'DELETE'; +} + +/** + * Split validated input into URL, query and body according to + * `handler.inputBindings` — the single place either mode is decided, so + * `call()` and the pre-payment `validateBackendRequestShape()` can never + * disagree about what request the input describes. + * + * Throws only `CommerceError('INPUT_INVALID')`, which is what makes it safe to + * run before pricing. + */ +function buildBackendRequestParts( + handler: BackendHandler, + input: Record, + context: ShapeContext, +): BackendRequestParts { + const bindings = handler.inputBindings; + if (bindings === undefined) { + const { url, remaining } = applyPathTemplate(handler.url, input, context); + return acceptsBody(handler.method) + ? { url, query: {}, body: { value: remaining } } + : { url, query: remaining }; } - if (handler.method === 'GET' || handler.method === 'DELETE') { - let target: URL; - try { - target = new URL(templated.url); - } catch { - return; // call() surfaces the real BACKEND_ERROR for an unparseable URL. - } - checkQueryCollision(target, templated.remaining, context); + const pathValues = + bindings.path === undefined ? {} : resolveBoundGroup(input, bindings.path, 'path', context); + const { url } = applyPathTemplate(handler.url, pathValues, context); + const query = + bindings.query === undefined ? {} : resolveBoundGroup(input, bindings.query, 'query', context); + + // An absent body value sends no body at all rather than `null`: a request + // body the operation does not require is simply not there. A body the + // operation *does* require is caught one step earlier, by `required` in the + // resource's input schema — also before payment. + const bodyValue = bindings.body === undefined ? undefined : input[bindings.body]; + if (bodyValue === undefined || !acceptsBody(handler.method)) return { url, query }; + return { url, query, body: { value: bodyValue } }; +} + +function resolveBoundGroup( + input: Record, + key: string, + kind: 'path' | 'query', + context: ShapeContext, +): Record { + const value = input[key]; + if (value === undefined) return {}; + if (!isPlainObject(value)) { + throw new CommerceError( + 'INPUT_INVALID', + `Input "${key}" must be an object of ${kind} parameters`, + { ...context, details: { field: key } }, + ); } + return value; } function checkQueryCollision( @@ -364,6 +389,7 @@ const TRAVERSAL_PATH_VALUES = new Set(['', '.', '..']); function applyPathTemplate( template: string, input: Record, + context: ShapeContext, ): { url: string; remaining: Record } { const remaining: Record = { ...input }; let missing: string | undefined; @@ -387,21 +413,27 @@ function applyPathTemplate( return encodeURIComponent(raw); }); if (missing !== undefined) { - throw new Error(`missing-path-parameter:${missing}`); + // Tempting to shrug this off as a config/schema mismatch rather than bad + // caller input. But a paid, `{param}`-templated resource whose input can + // never supply it would reach settle() on every call — the buyer pays, the + // backend is never called, no refund. `normaliseResource` (src/config) is + // the root-cause fix, rejecting the shape at config load; throwing here + // stops a hand-built `CommerceResource` from reintroducing the money bug + // by skipping config validation. + throw new CommerceError('INPUT_INVALID', `Path parameter "${missing}" was not supplied`, { + ...context, + details: { field: missing }, + }); } if (invalid !== undefined) { - throw new Error(`invalid-path-parameter:${invalid}`); + throw new CommerceError('INPUT_INVALID', `Path parameter "${invalid}" is not a valid value`, { + ...context, + details: { field: invalid }, + }); } return { url, remaining }; } -function describeConstructionError(error: unknown): string { - if (error instanceof Error && error.message.startsWith('missing-path-parameter:')) { - return error.message; - } - return 'invalid-url'; -} - function stringifyPrimitive(value: unknown): string { if (value === undefined || value === null) return ''; if (typeof value === 'string') return value; diff --git a/tests/unit/core/execution/backend-http.test.ts b/tests/unit/core/execution/backend-http.test.ts index 8237b98..0f96c5c 100644 --- a/tests/unit/core/execution/backend-http.test.ts +++ b/tests/unit/core/execution/backend-http.test.ts @@ -569,3 +569,178 @@ describe('HttpBackendExecutor', () => { ).not.toThrow(); }); }); + +describe('HttpBackendExecutor explicit inputBindings', () => { + function capturingExecutor(): { + executor: HttpBackendExecutor; + seen: { url?: URL; body?: string; headers: Record }; + } { + const seen: { url?: URL; body?: string; headers: Record } = { headers: {} }; + const fetchImpl = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + seen.url = new URL(input as URL); + const rawBody = init?.body as string | undefined; + if (rawBody !== undefined) seen.body = rawBody; + seen.headers = Object.fromEntries(new Headers(init?.headers).entries()); + return jsonResponse(200, { ok: true }); + }); + return { + executor: new HttpBackendExecutor({ fetchImpl: fetchImpl as unknown as typeof fetch }), + seen, + }; + } + + const postHandler: BackendHandler = { + type: 'http', + method: 'POST', + url: 'http://backend.local/users/{userId}/orders', + inputBindings: { path: 'path', query: 'query', body: 'body' }, + }; + + it('sources path, query and body independently on one POST', async () => { + const { executor, seen } = capturingExecutor(); + await executor.call(postHandler, { + requestId: 'r', + resourceId: 'res', + input: { + path: { userId: 'u-1' }, + query: { notify: true }, + body: { productId: 'abc', quantity: 2 }, + }, + }); + + expect(seen.url?.pathname).toBe('/users/u-1/orders'); + expect(seen.url?.searchParams.get('notify')).toBe('true'); + expect(JSON.parse(seen.body as string)).toEqual({ productId: 'abc', quantity: 2 }); + expect(seen.headers['content-type']).toBe('application/json'); + }); + + it('does not forward top-level input that no binding names', async () => { + const { executor, seen } = capturingExecutor(); + await executor.call(postHandler, { + requestId: 'r', + resourceId: 'res', + input: { + path: { userId: 'u-1' }, + body: { productId: 'abc' }, + // deliberately unmapped: an adapter- or agent-supplied extra + payment: 'base64-proof', + apiKey: 'leaked', + }, + }); + + expect(seen.url?.search).toBe(''); + expect(JSON.parse(seen.body as string)).toEqual({ productId: 'abc' }); + }); + + it('sends path + query with no body when the body binding resolves to nothing', async () => { + const { executor, seen } = capturingExecutor(); + await executor.call(postHandler, { + requestId: 'r', + resourceId: 'res', + input: { path: { userId: 'u-1' }, query: { notify: false } }, + }); + + expect(seen.url?.searchParams.get('notify')).toBe('false'); + expect(seen.body).toBeUndefined(); + expect(seen.headers['content-type']).toBeUndefined(); + }); + + it('keeps a configured Content-Type authoritative for an explicit body', async () => { + const { executor, seen } = capturingExecutor(); + await executor.call( + { ...postHandler, headers: { 'Content-Type': 'application/vnd.merchant+json' } }, + { + requestId: 'r', + resourceId: 'res', + input: { path: { userId: 'u-1' }, body: { productId: 'abc' } }, + }, + ); + + expect(seen.headers['content-type']).toBe('application/vnd.merchant+json'); + }); + + it('appends mapped query parameters on a GET and sends no body', async () => { + const { executor, seen } = capturingExecutor(); + await executor.call( + { + type: 'http', + method: 'GET', + url: 'http://backend.local/users/{userId}', + inputBindings: { path: 'path', query: 'query', body: 'body' }, + }, + { + requestId: 'r', + resourceId: 'res', + input: { path: { userId: 'u-1' }, query: { verbose: 1 }, body: { ignored: true } }, + }, + ); + + expect(seen.url?.pathname).toBe('/users/u-1'); + expect(seen.url?.searchParams.get('verbose')).toBe('1'); + expect(seen.body).toBeUndefined(); + }); + + const shapeContext = { requestId: 'r', resourceId: 'res' }; + + function expectInputInvalid(run: () => void): void { + try { + run(); + expect.unreachable(); + } catch (error) { + expect(isCommerceError(error) && error.code === 'INPUT_INVALID').toBe(true); + } + } + + it('rejects a mapped query collision with backend.url before payment', () => { + expectInputInvalid(() => + validateBackendRequestShape( + { + type: 'http', + method: 'POST', + url: 'http://backend.local/orders?apikey=SECRET', + inputBindings: { query: 'query', body: 'body' }, + }, + { query: { apikey: 'attacker' }, body: {} }, + shapeContext, + ), + ); + }); + + it('rejects a missing path group before payment', () => { + expectInputInvalid(() => + validateBackendRequestShape(postHandler, { body: { productId: 'abc' } }, shapeContext), + ); + }); + + it('rejects a non-object path group before payment', () => { + expectInputInvalid(() => + validateBackendRequestShape(postHandler, { path: 'u-1' }, shapeContext), + ); + }); + + it('rejects a non-object query group before payment', () => { + expectInputInvalid(() => + validateBackendRequestShape( + postHandler, + { path: { userId: 'u-1' }, query: 'notify=true' }, + shapeContext, + ), + ); + }); + + it('rejects a traversal path value inside the bound group before payment', () => { + expectInputInvalid(() => + validateBackendRequestShape(postHandler, { path: { userId: '..' } }, shapeContext), + ); + }); + + it('passes a valid path + query + body shape (control)', () => { + expect(() => + validateBackendRequestShape( + postHandler, + { path: { userId: 'u-1' }, query: { notify: true }, body: { productId: 'abc' } }, + shapeContext, + ), + ).not.toThrow(); + }); +}); From ee73251fd91b08fbde0e39f5815b91cd27b298d1 Mon Sep 17 00:00:00 2001 From: Revinand Date: Thu, 3 Sep 2026 19:47:07 +0200 Subject: [PATCH 2/8] feat(config): support backend input bindings --- src/config/schema.ts | 138 +++++++++++++++++++- tests/unit/config/schema.test.ts | 213 +++++++++++++++++++++++++++++++ 2 files changed, 348 insertions(+), 3 deletions(-) diff --git a/src/config/schema.ts b/src/config/schema.ts index 3fca809..00976a2 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -189,6 +189,19 @@ const DEFAULT_A2A_MOUNT_PATH = '/a2a'; const BackendMethodSchema = z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']); +/** + * Names the top-level input properties carrying each part of the backend + * request. Strict: a typo like `bodyy` must fail at load, not silently mean + * "no body binding" and ship a request missing its payload. + */ +const BackendInputBindingsSchema = z + .object({ + path: z.string().min(1).optional(), + query: z.string().min(1).optional(), + body: z.string().min(1).optional(), + }) + .strict(); + const BackendHandlerSchema = z .object({ type: z.literal('http'), @@ -196,6 +209,7 @@ const BackendHandlerSchema = z url: z.string().min(1), headers: z.record(z.string(), z.string()).optional(), timeoutMs: NumberOrString.optional(), + inputBindings: BackendInputBindingsSchema.optional(), }) .strict(); @@ -702,7 +716,8 @@ function normaliseResource( } validateBackendUrl(id, entry.backend.url); - validatePathParametersDeclared(id, entry.backend.url, entry.input); + const pathScope = validateInputBindings(id, entry.backend, entry.input); + validatePathParametersDeclared(id, entry.backend.url, pathScope.schema, pathScope.where); if (entry.pricing.type === 'dynamic') { throw new CommerceError( @@ -770,6 +785,9 @@ function normaliseResource( }), } : {}), + ...(entry.backend.inputBindings !== undefined + ? { inputBindings: pickDefined(entry.backend.inputBindings) } + : {}), }, pricing, exposedVia: entry.expose as CommerceResource['exposedVia'], @@ -1039,6 +1057,119 @@ function validatePricingAmount(id: string, amount: string, x402: NormalisedX402 } } +/** + * `backend.inputBindings` names top-level input properties; this is the gate + * that makes those names mean something at load time rather than at the first + * paid call. Input schemas are closed by default at every depth + * (`defaultClosedObjectSchema`), so a binding naming a property the schema + * never declares can never be satisfied — the group would be silently empty + * on every request, which on a paid resource is payment for a request the + * backend receives incomplete. + * + * Returns the schema node `{param}` declarations must be found in: the path + * group in explicit mode, the whole input in legacy mode. + */ +function validateInputBindings( + id: string, + backend: RawResourceEntry['backend'], + input: Record | undefined, +): { readonly schema: Record | undefined; readonly where: string } { + const legacy = { schema: input, where: 'its input schema' } as const; + const bindings = backend.inputBindings; + const templated = extractPathParameterNames(backend.url).length > 0; + if (bindings === undefined) return legacy; + + const path = `resources.${id}.backend.inputBindings`; + const fail = (message: string, details: Record = {}): never => { + throw new CommerceError('CONFIG_INVALID', `Resource "${id}" ${message}`, { + details: { path, resourceId: id, ...details }, + }); + }; + + const entries = Object.entries(bindings).filter( + (entry): entry is [string, string] => entry[1] !== undefined, + ); + if (entries.length === 0) { + fail('has an empty backend.inputBindings — remove the block to use the default mapping'); + } + if (backend.method === 'GET' || backend.method === 'DELETE') { + if (bindings.body !== undefined) { + fail( + `binds a request body on a ${backend.method}, which sends none — the value would be silently dropped`, + ); + } + } + if (templated && bindings.path === undefined) { + fail( + 'has backend.url path parameters but no "path" binding — in explicit binding mode nothing else supplies them, so every call would fail to reach the backend', + ); + } + + const seen = new Map(); + const properties = isPlainObject(input?.['properties']) ? input['properties'] : {}; + const required = new Set( + Array.isArray(input?.['required']) + ? input['required'].filter((value): value is string => typeof value === 'string') + : [], + ); + + for (const [location, property] of entries) { + if (property === PAYMENT_INPUT_FIELD) { + fail( + `binds "${location}" to "${PAYMENT_INPUT_FIELD}", which is reserved for payment proofs`, + { location }, + ); + } + const other = seen.get(property); + if (other !== undefined) { + fail(`binds both "${other}" and "${location}" to the input property "${property}"`, { + location, + property, + }); + } + seen.set(property, location); + + if (!Object.hasOwn(properties, property)) { + fail( + `binds "${location}" to input property "${property}", which the input schema does not declare — the schema is closed, so a caller could never supply it`, + { location, property }, + ); + } + // `body` may legitimately be any JSON value; only the two groups the + // executor iterates as key/value pairs have to be objects. + const declared = properties[property]; + if (location !== 'body' && isPlainObject(declared) && !isObjectSchemaNode(declared)) { + fail( + `binds "${location}" to input property "${property}", which is not an object schema — ${location} parameters are read as an object of name/value pairs`, + { location, property }, + ); + } + } + + if (templated && bindings.path !== undefined && !required.has(bindings.path)) { + fail( + `binds path parameters to input property "${bindings.path}" without listing it in the input schema's "required" — a caller that omits it cannot supply any path parameter, so the request could never be built`, + { property: bindings.path }, + ); + } + + if (bindings.path === undefined) return { schema: undefined, where: 'its input schema' }; + const group = properties[bindings.path]; + return { + schema: isPlainObject(group) ? group : undefined, + where: `input.properties.${bindings.path}`, + }; +} + +/** Strips absent optional keys so the result satisfies `exactOptionalPropertyTypes`. */ +function pickDefined>( + value: T, +): { [K in keyof T]?: string } { + return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== undefined)) as { + [K in keyof T]?: string; + }; +} + /** * The root-cause half. `validateBackendRequestShape` * (src/core) rejects a missing path parameter at request time — after @@ -1058,6 +1189,7 @@ function validatePathParametersDeclared( id: string, url: string, input: Record | undefined, + where: string, ): void { // Before anything else: a brace token the canonical grammar does not // recognise is neither extracted here nor substituted at request time, so it @@ -1121,14 +1253,14 @@ function validatePathParametersDeclared( if (!Object.hasOwn(properties, param)) { throw new CommerceError( 'CONFIG_INVALID', - `Resource "${id}" has backend.url path parameter "{${param}}" which is not declared in its input schema — the caller has no way to supply it, so every call would settle payment (if priced) and then fail to reach the backend`, + `Resource "${id}" has backend.url path parameter "{${param}}" which is not declared in ${where} — the caller has no way to supply it, so every call would settle payment (if priced) and then fail to reach the backend`, { details: { path, resourceId: id, param } }, ); } if (!required.has(param)) { throw new CommerceError( 'CONFIG_INVALID', - `Resource "${id}" has backend.url path parameter "{${param}}" declared in its input schema but not listed in "required" — a caller that omits it hits the same unservable-request problem as an undeclared parameter`, + `Resource "${id}" has backend.url path parameter "{${param}}" declared in ${where} but not listed in its "required" — a caller that omits it hits the same unservable-request problem as an undeclared parameter`, { details: { path, resourceId: id, param } }, ); } diff --git a/tests/unit/config/schema.test.ts b/tests/unit/config/schema.test.ts index 3ddab03..5183465 100644 --- a/tests/unit/config/schema.test.ts +++ b/tests/unit/config/schema.test.ts @@ -1465,3 +1465,216 @@ describe('protocols.a2a', () => { expect(config.protocols.a2a.enabled).toBe(false); }); }); + +describe('parseConfig backend.inputBindings', () => { + /** A config whose one resource is an OpenAPI-shaped path + query + body POST. */ + function bindingConfig( + overrides: { + readonly bindings?: unknown; + readonly method?: string; + readonly url?: string; + readonly input?: unknown; + } = {}, + ): Record { + const raw = validRawConfig(); + const resources = raw['resources'] as Record; + raw['resources'] = { + create_order: { + name: 'Create Order', + input: overrides.input ?? { + type: 'object', + properties: { + path: { + type: 'object', + properties: { userId: { type: 'string' } }, + required: ['userId'], + }, + query: { type: 'object', properties: { notify: { type: 'boolean' } } }, + body: { type: 'object', properties: { productId: { type: 'string' } } }, + }, + required: ['path'], + additionalProperties: false, + }, + backend: { + type: 'http', + method: overrides.method ?? 'POST', + url: overrides.url ?? 'http://localhost:3000/users/{userId}/orders', + ...(overrides.bindings !== undefined ? { inputBindings: overrides.bindings } : {}), + }, + pricing: { type: 'free' }, + expose: ['http'], + }, + market_report: resources['market_report'], + }; + return raw; + } + + const bindings = { path: 'path', query: 'query', body: 'body' }; + + it('parses the block and normalises it onto the canonical handler', () => { + const config = parseConfig(bindingConfig({ bindings }), {}); + const resource = config.resources.find((r) => r.id === 'create_order'); + expect(resource?.handler.inputBindings).toEqual(bindings); + }); + + it('leaves the handler unbound when the block is absent (existing configs)', () => { + const config = parseConfig(validRawConfig(), {}); + for (const resource of config.resources) { + expect(resource.handler.inputBindings).toBeUndefined(); + } + }); + + it('omits absent binding keys rather than setting them undefined', () => { + const config = parseConfig(bindingConfig({ bindings: { path: 'path' } }), {}); + const resource = config.resources.find((r) => r.id === 'create_order'); + expect(Object.keys(resource?.handler.inputBindings ?? {})).toEqual(['path']); + }); + + it('rejects an unknown binding location (typo)', () => { + expectConfigInvalid(() => + parseConfig(bindingConfig({ bindings: { ...bindings, bodyy: 'body' } }), {}), + ); + }); + + it('rejects an empty binding block', () => { + expectConfigInvalid(() => parseConfig(bindingConfig({ bindings: {} }), {})); + }); + + it('rejects a binding to a property the input schema never declares', () => { + expectConfigInvalid(() => + parseConfig(bindingConfig({ bindings: { ...bindings, query: 'filters' } }), {}), + ); + }); + + it('rejects a path or query binding pointing at a non-object schema', () => { + expectConfigInvalid(() => + parseConfig( + bindingConfig({ + bindings, + input: { + type: 'object', + properties: { + path: { type: 'object', properties: { userId: { type: 'string' } } }, + query: { type: 'string' }, + body: { type: 'object' }, + }, + required: ['path'], + }, + }), + {}, + ), + ); + }); + + it('rejects two locations bound to the same input property', () => { + expectConfigInvalid(() => + parseConfig(bindingConfig({ bindings: { path: 'path', query: 'path' } }), {}), + ); + }); + + it('rejects a binding to the reserved payment input field', () => { + expectConfigInvalid(() => + parseConfig(bindingConfig({ bindings: { ...bindings, body: PAYMENT_INPUT_FIELD } }), {}), + ); + }); + + it('rejects a body binding on a method that sends no body', () => { + expectConfigInvalid(() => + parseConfig( + bindingConfig({ + bindings, + method: 'GET', + url: 'http://localhost:3000/users/{userId}', + }), + {}, + ), + ); + }); + + it('rejects explicit bindings that omit "path" while backend.url is templated', () => { + expectConfigInvalid(() => + parseConfig(bindingConfig({ bindings: { query: 'query', body: 'body' } }), {}), + ); + }); + + it('rejects a path group that is not itself required', () => { + expectConfigInvalid(() => + parseConfig( + bindingConfig({ + bindings, + input: { + type: 'object', + properties: { + path: { + type: 'object', + properties: { userId: { type: 'string' } }, + required: ['userId'], + }, + query: { type: 'object' }, + body: { type: 'object' }, + }, + required: [], + }, + }), + {}, + ), + ); + }); + + it('rejects a {param} that is not declared inside the bound path group', () => { + expectConfigInvalid(() => + parseConfig( + bindingConfig({ + bindings, + // userId declared at the top level, not under the path group — the + // pre-bindings shape, which explicit mode no longer reads from. + input: { + type: 'object', + properties: { + userId: { type: 'string' }, + path: { type: 'object', properties: {}, required: [] }, + query: { type: 'object' }, + body: { type: 'object' }, + }, + required: ['path', 'userId'], + }, + }), + {}, + ), + ); + }); + + it('rejects a nested {param} that is declared but not required', () => { + expectConfigInvalid(() => + parseConfig( + bindingConfig({ + bindings, + input: { + type: 'object', + properties: { + path: { type: 'object', properties: { userId: { type: 'string' } }, required: [] }, + query: { type: 'object' }, + body: { type: 'object' }, + }, + required: ['path'], + }, + }), + {}, + ), + ); + }); + + it('accepts the normalised handler as input to the pre-payment shape check', () => { + const config = parseConfig(bindingConfig({ bindings }), {}); + const resource = config.resources.find((r) => r.id === 'create_order'); + expect(resource).toBeDefined(); + if (!resource) return; + expect(() => + validateBackendRequestShape( + resource.handler, + { path: { userId: 'u-1' }, query: { notify: true }, body: { productId: 'abc' } }, + { requestId: 'r', resourceId: resource.id }, + ), + ).not.toThrow(); + }); +}); From 292a14190d3a1c56f83382bd6a1a00fd9999adba Mon Sep 17 00:00:00 2001 From: Revinand Date: Thu, 3 Sep 2026 21:36:22 +0200 Subject: [PATCH 3/8] feat(openapi): add loader and operation discovery --- README.md | 6 +- package-lock.json | 179 ++++++++++- package.json | 11 +- src/openapi/discover.ts | 296 ++++++++++++++++++ src/openapi/index.ts | 14 + src/openapi/load.ts | 162 ++++++++++ src/openapi/refs.ts | 105 +++++++ src/openapi/types.ts | 52 +++ tests/unit/cli/packaging.test.ts | 23 +- tests/unit/openapi/discover.test.ts | 142 +++++++++ tests/unit/openapi/fixtures/collision.yaml | 19 ++ tests/unit/openapi/fixtures/cyclic-ref.yaml | 26 ++ .../openapi/fixtures/external-file-ref.yaml | 17 + .../openapi/fixtures/external-http-ref.yaml | 15 + tests/unit/openapi/fixtures/invalid.yaml | 5 + tests/unit/openapi/fixtures/minimal-3.1.json | 10 + tests/unit/openapi/fixtures/not-openapi.yaml | 3 + tests/unit/openapi/fixtures/petstore-3.0.yaml | 54 ++++ .../openapi/fixtures/relative-server.yaml | 13 + tests/unit/openapi/fixtures/swagger-2.0.yaml | 5 + .../unit/openapi/fixtures/variables-3.2.yaml | 33 ++ tests/unit/openapi/load.test.ts | 140 +++++++++ tests/unit/openapi/refs.test.ts | 73 +++++ 23 files changed, 1394 insertions(+), 9 deletions(-) create mode 100644 src/openapi/discover.ts create mode 100644 src/openapi/index.ts create mode 100644 src/openapi/load.ts create mode 100644 src/openapi/refs.ts create mode 100644 src/openapi/types.ts create mode 100644 tests/unit/openapi/discover.test.ts create mode 100644 tests/unit/openapi/fixtures/collision.yaml create mode 100644 tests/unit/openapi/fixtures/cyclic-ref.yaml create mode 100644 tests/unit/openapi/fixtures/external-file-ref.yaml create mode 100644 tests/unit/openapi/fixtures/external-http-ref.yaml create mode 100644 tests/unit/openapi/fixtures/invalid.yaml create mode 100644 tests/unit/openapi/fixtures/minimal-3.1.json create mode 100644 tests/unit/openapi/fixtures/not-openapi.yaml create mode 100644 tests/unit/openapi/fixtures/petstore-3.0.yaml create mode 100644 tests/unit/openapi/fixtures/relative-server.yaml create mode 100644 tests/unit/openapi/fixtures/swagger-2.0.yaml create mode 100644 tests/unit/openapi/fixtures/variables-3.2.yaml create mode 100644 tests/unit/openapi/load.test.ts create mode 100644 tests/unit/openapi/refs.test.ts diff --git a/README.md b/README.md index 7416185..2de6382 100644 --- a/README.md +++ b/README.md @@ -69,8 +69,10 @@ agent-commerce doctor Requires **Node >= 22**. One package ships two things: the `agent-commerce` CLI (`init`, `validate`, `doctor`, `demo`) and a library for embedding the -gateway in your own process. A default install is ~49 MB and pulls no -blockchain or wallet dependencies at all. +gateway in your own process. A default install is ~65 MB and pulls no +blockchain or wallet dependencies at all — ~17 MB of that is the OpenAPI +parser behind `import openapi`, which is a normal dependency because +onboarding an existing API is the CLI's main job. ```ts import { createGateway, loadConfig, receipts } from '@devlab.group/agent-commerce'; diff --git a/package-lock.json b/package-lock.json index 0194b7b..d396e64 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "Apache-2.0", "dependencies": { "@clack/prompts": "1.7.0", + "@scalar/openapi-parser": "0.29.0", "better-sqlite3": "13.0.3", "commander": "15.0.0", "fastify": "5.12.0", @@ -1769,6 +1770,142 @@ "win32" ] }, + "node_modules/@scalar/helpers": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/@scalar/helpers/-/helpers-0.11.2.tgz", + "integrity": "sha512-IgHW/hIj1XAvsPIBKiOgmK87qbyDjaQQg9S/auXU4MUtvnwKKpOeIOJc0NC71FkMdr4Mok0SSVI748cmlptoXQ==", + "license": "MIT", + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/json-magic": { + "version": "0.13.3", + "resolved": "https://registry.npmjs.org/@scalar/json-magic/-/json-magic-0.13.3.tgz", + "integrity": "sha512-ONbveMHPl8ashCnTH/IzF2skr7CKUAC97m2tWQpbWJXhkUvc/B9vSRirjM0IqPCDURn4WZWAQFoaehDiEJICWA==", + "license": "MIT", + "dependencies": { + "@scalar/helpers": "0.11.2", + "pathe": "^2.0.3", + "yaml": "^2.9.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/json-schema-validator": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@scalar/json-schema-validator/-/json-schema-validator-0.1.0.tgz", + "integrity": "sha512-3mOin8TlRqhH9CndcZeOGFicb+/WSPmclko7vnaTIIa8vqrwZ6ZtwLdsZQpU1xzM5NqMgumF35ii3KEPS7kNDA==", + "license": "MIT", + "dependencies": { + "@scalar/helpers": "0.11.2", + "@scalar/types": "0.18.3", + "ajv": "^8.20.0", + "ajv-draft-04": "^1.0.0", + "ajv-formats": "^3.0.1", + "yaml": "^2.9.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/openapi-parser": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@scalar/openapi-parser/-/openapi-parser-0.29.0.tgz", + "integrity": "sha512-RVPVidIG5ohEUqPdB70kabhvAP1wq54iWwwdD9TKfJC73OSGUW2+7sHPFEwAD2MIwX9iDMOtxlSIHIk8Lidwdw==", + "license": "MIT", + "dependencies": { + "@scalar/helpers": "0.11.2", + "@scalar/json-magic": "0.13.3", + "@scalar/openapi-types": "0.9.5", + "@scalar/openapi-upgrader": "0.2.15", + "@scalar/openapi-validator": "0.1.0", + "@scalar/types": "0.18.3", + "yaml": "^2.9.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/openapi-types": { + "version": "0.9.5", + "resolved": "https://registry.npmjs.org/@scalar/openapi-types/-/openapi-types-0.9.5.tgz", + "integrity": "sha512-czrz/zkVm1oPzrpYo3hI/iymfiw1s4dgJiQtwNi2U77Sqf3EQOGKsif4VNRa5suWMYRuPaFx5EiFM1FNEV4Whg==", + "license": "MIT", + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/openapi-upgrader": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/@scalar/openapi-upgrader/-/openapi-upgrader-0.2.15.tgz", + "integrity": "sha512-yqROK9U96ElasEL4Wl/+PIjQZlqrQXVUUpXk9PA6xBnrp8KdEv7at8pR5gsZkvddJfYVwLILPlctyqUg4u4YZA==", + "license": "MIT", + "dependencies": { + "@scalar/openapi-types": "0.9.5" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/openapi-validator": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@scalar/openapi-validator/-/openapi-validator-0.1.0.tgz", + "integrity": "sha512-ZwUibJj0nPCdhRXCdgOvWme3EcywDPIwsfKCh912AMOWjSw4EF5Iv9MfAsKC+y8k8vrQEZ3JRcVvJcq3OwWtQQ==", + "license": "MIT", + "dependencies": { + "@scalar/helpers": "0.11.2", + "@scalar/json-schema-validator": "0.1.0", + "@scalar/types": "0.18.3", + "yaml": "^2.9.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/types": { + "version": "0.18.3", + "resolved": "https://registry.npmjs.org/@scalar/types/-/types-0.18.3.tgz", + "integrity": "sha512-hPLLxVt/ah4RpCVp5UrLEpnBEgTwf+RvPtRiSEty3QqfBJUfADMXHz+oWK6UAa/vALg2AWuJLCD8hRlyQ2ET9w==", + "license": "MIT", + "dependencies": { + "@scalar/helpers": "0.11.2", + "nanoid": "^5.1.6", + "type-fest": "^5.8.0", + "zod": "^4.4.3" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/types/node_modules/nanoid": { + "version": "5.1.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.16.tgz", + "integrity": "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, + "node_modules/@scalar/types/node_modules/zod": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.5.4.tgz", + "integrity": "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@scure/base": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz", @@ -3140,6 +3277,20 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ajv-draft-04": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz", + "integrity": "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==", + "license": "MIT", + "peerDependencies": { + "ajv": "^8.5.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/ajv-formats": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", @@ -5255,7 +5406,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, "license": "MIT" }, "node_modules/picocolors": { @@ -6119,6 +6269,18 @@ "node": ">=8" } }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", @@ -6793,6 +6955,21 @@ "@esbuild/win32-x64": "0.28.2" } }, + "node_modules/type-fest": { + "version": "5.9.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.9.0.tgz", + "integrity": "sha512-yANm3Jr3GiJ1qgJlxGAVxTOIcEOk1rhQHamlXtnrCK7EHP4HeM9OGxtMg/W7HFdrVzw/ZWJKGVIJusVH85sLtw==", + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/type-is": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", diff --git a/package.json b/package.json index 365cd3e..ed75bed 100644 --- a/package.json +++ b/package.json @@ -88,6 +88,7 @@ }, "dependencies": { "@clack/prompts": "1.7.0", + "@scalar/openapi-parser": "0.29.0", "better-sqlite3": "13.0.3", "commander": "15.0.0", "fastify": "5.12.0", @@ -144,6 +145,14 @@ "vitest": "4.1.10" }, "overrides": { - "zod": "3.25.76" + "@x402/core": { + "zod": "3.25.76" + }, + "@x402/evm": { + "zod": "3.25.76" + }, + "@coinbase/x402": { + "zod": "3.25.76" + } } } diff --git a/src/openapi/discover.ts b/src/openapi/discover.ts new file mode 100644 index 0000000..7366d1e --- /dev/null +++ b/src/openapi/discover.ts @@ -0,0 +1,296 @@ +/** + * Turns OpenAPI path operations into a deterministic list of candidates. + * + * Determinism is the point: a resource id is what an agent discovers and + * hard-codes, so it must depend only on the document, never on iteration order + * or on how many times the importer has run. Anything ambiguous fails the + * import instead of being silently renamed. + */ +import type { BackendMethod } from '../core/domain/resource.js'; +import { CommerceError } from '../core/errors/index.js'; +import { findUnparsedBraceToken } from '../core/execution/index.js'; +import { dereference } from './refs.js'; +import type { + ImportDiagnostic, + LoadedOpenApiDocument, + OpenApiOperationCandidate, +} from './types.js'; + +const SUPPORTED_METHODS: Readonly> = { + get: 'GET', + post: 'POST', + put: 'PUT', + patch: 'PATCH', + delete: 'DELETE', +}; + +/** Path Item members that are not operations. Anything else that is not a + * supported method gets an explicit diagnostic rather than silent skipping. */ +const NON_OPERATION_KEYS = new Set(['summary', 'description', 'servers', 'parameters', '$ref']); + +/** The id character set Agent Commerce and MCP tool names already share. */ +const ID_ALLOWED = /[^A-Za-z0-9_.-]+/g; +const MAX_ID_LENGTH = 128; + +export interface DiscoverOptions { + /** CLI `--base-url`. Wins over every server declared in the document. */ + readonly baseUrl?: string; +} + +export interface DiscoveryResult { + readonly operations: readonly OpenApiOperationCandidate[]; + readonly diagnostics: readonly ImportDiagnostic[]; +} + +export function discoverOperations( + loaded: LoadedOpenApiDocument, + options: DiscoverOptions = {}, +): DiscoveryResult { + const { document } = loaded; + const diagnostics: ImportDiagnostic[] = []; + const operations: OpenApiOperationCandidate[] = []; + /** resource id -> the `METHOD path` that claimed it, for the collision message. */ + const claimed = new Map(); + + if (options.baseUrl !== undefined) assertAbsoluteHttpUrl(options.baseUrl, '--base-url'); + + const paths = document['paths']; + if (!isRecord(paths)) { + return { operations, diagnostics }; + } + + const rootServers = document['servers']; + const rootSecurity = document['security']; + + for (const [path, rawPathItem] of Object.entries(paths)) { + if (path.startsWith('x-')) continue; + if (!path.startsWith('/')) { + diagnostics.push({ + severity: 'warning', + code: 'invalid-path-key', + operation: path, + message: `Skipped "${path}": a Paths key must start with "/"`, + }); + continue; + } + const pathItem = dereference(document, rawPathItem).value; + if (!isRecord(pathItem)) continue; + + for (const [key, rawOperation] of Object.entries(pathItem)) { + if (NON_OPERATION_KEYS.has(key) || key.startsWith('x-')) continue; + const method = SUPPORTED_METHODS[key.toLowerCase()]; + if (method === undefined) { + diagnostics.push({ + severity: 'warning', + code: 'unsupported-method', + operation: `${key.toUpperCase()} ${path}`, + message: `Skipped ${key.toUpperCase()} ${path}: only GET, POST, PUT, PATCH and DELETE are supported`, + }); + continue; + } + const operation = dereference(document, rawOperation).value; + if (!isRecord(operation)) continue; + + const where = `${method} ${path}`; + const operationId = + typeof operation['operationId'] === 'string' ? operation['operationId'] : undefined; + const resourceId = toResourceId(operationId, method, path); + const previous = claimed.get(resourceId); + if (previous !== undefined) { + throw new CommerceError( + 'CONFIG_INVALID', + `Operations "${previous}" and "${where}" both produce the resource id "${resourceId}". Give one of them a distinct operationId — ids are what agents discover, so the importer will not rename either`, + { details: { resourceId, operations: [previous, where] } }, + ); + } + claimed.set(resourceId, where); + + const server = selectServer( + document, + [operation['servers'], pathItem['servers'], rootServers], + { where, resourceId }, + options, + diagnostics, + ); + if (server === undefined) continue; + + const backendUrl = `${server.replace(/\/+$/, '')}${path}`; + const stray = findUnparsedBraceToken(backendUrl); + if (stray !== undefined) { + diagnostics.push({ + severity: 'error', + code: 'unsupported-path-template', + operation: resourceId, + message: `Skipped ${where}: path template "${stray}" uses characters the gateway cannot substitute (allowed: A-Z a-z 0-9 _ . -)`, + }); + continue; + } + + const summary = typeof operation['summary'] === 'string' ? operation['summary'] : undefined; + const description = + typeof operation['description'] === 'string' ? operation['description'] : summary; + const security = operation['security'] ?? rootSecurity; + + operations.push({ + resourceId, + method, + path, + backendUrl, + ...(operationId !== undefined ? { operationId } : {}), + name: summary ?? operationId ?? resourceId, + ...(description !== undefined ? { description } : {}), + parameters: [ + ...toArray(pathItem['parameters']), + // Operation parameters last: Phase 4 lets the later one win, which is + // the OpenAPI override rule (same name + in). + ...toArray(operation['parameters']), + ], + ...(operation['requestBody'] !== undefined + ? { requestBody: operation['requestBody'] } + : {}), + ...(operation['responses'] !== undefined ? { responses: operation['responses'] } : {}), + security: toArray(security), + }); + } + } + + return { operations, diagnostics }; +} + +/** + * `operationId` if it survives normalisation, otherwise `method_path`. + * + * No counters and no random suffixes: two runs over the same document must + * produce the same ids, and a collision is reported rather than papered over. + */ +function toResourceId( + operationId: string | undefined, + method: BackendMethod, + path: string, +): string { + const fromOperationId = operationId === undefined ? '' : normaliseId(operationId); + if (fromOperationId !== '') return fromOperationId; + return normaliseId(`${method.toLowerCase()}_${path}`); +} + +function normaliseId(value: string): string { + return value + .replace(ID_ALLOWED, '_') + .replace(/_+/g, '_') + .replace(/^[_.-]+|[_.-]+$/g, '') + .slice(0, MAX_ID_LENGTH); +} + +/** + * `--base-url` > operation servers > path-item servers > root servers. + * + * A relative server URL (`/v1`, the OpenAPI default of `/`) names no host, and + * guessing one from the filename or from localhost would silently point a + * merchant's gateway at the wrong backend — so it is refused and `--base-url` + * asked for instead. + */ +function selectServer( + document: Record, + candidates: readonly unknown[], + context: { readonly where: string; readonly resourceId: string }, + options: DiscoverOptions, + diagnostics: ImportDiagnostic[], +): string | undefined { + if (options.baseUrl !== undefined) return options.baseUrl; + + for (const candidate of candidates) { + const servers = toArray(candidate); + const first = servers.length > 0 ? dereference(document, servers[0]).value : undefined; + if (!isRecord(first)) continue; + const url = first['url']; + if (typeof url !== 'string' || url === '') continue; + + const substituted = substituteServerVariables(url, first['variables'], context, diagnostics); + if (substituted === undefined) return undefined; + if (!isAbsoluteHttpUrl(substituted)) { + diagnostics.push({ + severity: 'error', + code: 'relative-server-url', + operation: context.resourceId, + message: `Skipped ${context.where}: server URL "${substituted}" is relative, so no backend host is known. Pass --base-url`, + }); + return undefined; + } + return substituted; + } + + diagnostics.push({ + severity: 'error', + code: 'no-server-url', + operation: context.resourceId, + message: `Skipped ${context.where}: the document declares no server URL. Pass --base-url`, + }); + return undefined; +} + +function substituteServerVariables( + url: string, + variables: unknown, + context: { readonly where: string; readonly resourceId: string }, + diagnostics: ImportDiagnostic[], +): string | undefined { + if (!url.includes('{')) return url; + const declared = isRecord(variables) ? variables : {}; + const used: string[] = []; + let missing: string | undefined; + const substituted = url.replace(/\{([^{}]*)\}/g, (match, name: string) => { + const variable = declared[name]; + const fallback = isRecord(variable) ? variable['default'] : undefined; + if (typeof fallback !== 'string') { + missing = name; + return match; + } + used.push(`${name}=${fallback}`); + return fallback; + }); + if (missing !== undefined) { + diagnostics.push({ + severity: 'error', + code: 'server-variable-without-default', + operation: context.resourceId, + message: `Skipped ${context.where}: server variable "{${missing}}" has no default value. Pass --base-url`, + }); + return undefined; + } + if (used.length > 0) { + diagnostics.push({ + severity: 'warning', + code: 'server-variable-default', + operation: context.resourceId, + message: `Server URL for ${context.where} uses declared defaults (${used.join(', ')}); override with --base-url if that is not the deployment you mean`, + }); + } + return substituted; +} + +function isAbsoluteHttpUrl(value: string): boolean { + try { + const parsed = new URL(value); + return parsed.protocol === 'http:' || parsed.protocol === 'https:'; + } catch { + return false; + } +} + +function assertAbsoluteHttpUrl(value: string, label: string): void { + if (!isAbsoluteHttpUrl(value)) { + throw new CommerceError( + 'CONFIG_INVALID', + `${label} "${value}" must be an absolute http:// or https:// URL`, + { details: { value } }, + ); + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function toArray(value: unknown): readonly unknown[] { + return Array.isArray(value) ? value : []; +} diff --git a/src/openapi/index.ts b/src/openapi/index.ts new file mode 100644 index 0000000..f7972ad --- /dev/null +++ b/src/openapi/index.ts @@ -0,0 +1,14 @@ +/** + * OpenAPI import. Internal to this package: it produces Agent Commerce + * resource drafts for a human to review, and nothing here is on the runtime + * path or in the frozen contract. + */ +export { type DiscoverOptions, type DiscoveryResult, discoverOperations } from './discover.js'; +export { loadOpenApiDocument, MAX_SOURCE_BYTES } from './load.js'; +export { dereference, isRefNode } from './refs.js'; +export type { + ImportDiagnostic, + LoadedOpenApiDocument, + OpenApiOperationCandidate, + OpenApiVersion, +} from './types.js'; diff --git a/src/openapi/load.ts b/src/openapi/load.ts new file mode 100644 index 0000000..0bb5be1 --- /dev/null +++ b/src/openapi/load.ts @@ -0,0 +1,162 @@ +/** + * Reads and validates a local OpenAPI description. + * + * Offline by construction: the document is parsed here, every `$ref` is + * checked to be internal *before* the validator ever sees the document, and no + * URL-fetching plugin is passed to it. The importer must not turn "point it at + * your API description" into "the gateway machine makes outbound requests to + * whatever the file names". + */ +import { readFile, stat } from 'node:fs/promises'; +import { extname } from 'node:path'; +import { validate } from '@scalar/openapi-parser'; +import { parse as parseYaml } from 'yaml'; +import { CommerceError } from '../core/errors/index.js'; +import type { LoadedOpenApiDocument, OpenApiVersion } from './types.js'; + +/** Generous for a hand-written description; small enough that a stray file is refused. */ +export const MAX_SOURCE_BYTES = 10 * 1024 * 1024; + +const SUPPORTED_EXTENSIONS = new Set(['.yaml', '.yml', '.json']); +const SUPPORTED_VERSIONS: ReadonlySet = new Set(['3.0', '3.1', '3.2']); + +function invalid( + message: string, + details: Record, + cause?: unknown, +): CommerceError { + return new CommerceError('CONFIG_INVALID', message, { + details, + ...(cause !== undefined ? { cause } : {}), + }); +} + +export async function loadOpenApiDocument(sourcePath: string): Promise { + const details = { sourcePath }; + + let stats: Awaited>; + try { + stats = await stat(sourcePath); + } catch (error) { + throw invalid(`OpenAPI document "${sourcePath}" could not be read`, details, error); + } + if (stats.isDirectory()) { + throw invalid(`"${sourcePath}" is a directory, not an OpenAPI document`, details); + } + if (stats.size > MAX_SOURCE_BYTES) { + throw invalid( + `OpenAPI document "${sourcePath}" is ${stats.size} bytes, over the ${MAX_SOURCE_BYTES}-byte limit`, + { ...details, size: stats.size, maxBytes: MAX_SOURCE_BYTES }, + ); + } + + const extension = extname(sourcePath).toLowerCase(); + if (!SUPPORTED_EXTENSIONS.has(extension)) { + throw invalid( + `OpenAPI document "${sourcePath}" has an unsupported extension "${extension || '(none)'}". Supported: .yaml, .yml, .json`, + details, + ); + } + + const source = await readFile(sourcePath, 'utf8'); + if (source.trim() === '') { + throw invalid(`OpenAPI document "${sourcePath}" is empty`, details); + } + + let parsed: unknown; + try { + // YAML is a superset of JSON, but JSON.parse gives the better message for a + // file that claims to be JSON, and refuses YAML that a .json file should + // not contain. + parsed = extension === '.json' ? JSON.parse(source) : parseYaml(source); + } catch (error) { + throw invalid( + `OpenAPI document "${sourcePath}" is not valid ${extension === '.json' ? 'JSON' : 'YAML'}: ${error instanceof Error ? error.message : String(error)}`, + details, + error, + ); + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw invalid(`OpenAPI document "${sourcePath}" is not an object`, details); + } + const document = parsed as Record; + + const version = readVersion(document, sourcePath); + // Before validation, not after: the validator resolves references, and the + // no-network guarantee is only worth something if nothing external ever + // reaches it. + rejectExternalReferences(document, sourcePath); + + const result = await validate(document); + if (!result.valid) { + const first = result.errors?.[0]; + throw invalid( + `OpenAPI document "${sourcePath}" is not a valid OpenAPI ${version} description: ${first ? `${first.message}${first.path ? ` (at ${first.path})` : ''}` : 'unknown validation error'}`, + { ...details, errors: result.errors?.slice(0, 10) ?? [] }, + ); + } + + return { version, document, sourcePath }; +} + +/** + * The `openapi` field decides, not the validator's own verdict: the validator + * happily calls a Swagger 2.0 document valid, and importing one would mean + * `host`/`basePath`/`schemes` and body-parameter conversion semantics that + * nothing downstream understands. Refuse it by name so the operator is told to + * convert rather than left guessing. + */ +function readVersion(document: Record, sourcePath: string): OpenApiVersion { + const details = { sourcePath }; + if (typeof document['swagger'] === 'string') { + throw invalid( + `"${sourcePath}" is a Swagger ${document['swagger']} document. Only OpenAPI 3.0, 3.1 and 3.2 are supported — convert it first`, + details, + ); + } + const declared = document['openapi']; + if (typeof declared !== 'string') { + throw invalid(`"${sourcePath}" has no "openapi" version field`, details); + } + const featureVersion = /^(\d+\.\d+)(?:\.|$)/.exec(declared)?.[1]; + if (featureVersion === undefined || !SUPPORTED_VERSIONS.has(featureVersion)) { + throw invalid( + `OpenAPI version "${declared}" is not supported. Supported: 3.0.x, 3.1.x, 3.2.x`, + { ...details, version: declared }, + ); + } + return featureVersion as OpenApiVersion; +} + +/** + * An external `$ref` is refused rather than fetched or read from disk. Both + * would be the importer acting on behalf of a document it was merely asked to + * read — one as an outbound request from wherever the CLI runs, the other as a + * filesystem read outside the source file. Multi-file descriptions are a later + * feature; until then, saying so beats a silent partial import. + */ +function rejectExternalReferences(document: Record, sourcePath: string): void { + // Iterative with a seen set: YAML aliases can make the parsed graph cyclic, + // and a deeply nested document would otherwise blow the call stack. + const seen = new WeakSet(); + const queue: unknown[] = [document]; + while (queue.length > 0) { + const node = queue.pop(); + if (typeof node !== 'object' || node === null) continue; + if (seen.has(node)) continue; + seen.add(node); + if (Array.isArray(node)) { + queue.push(...node); + continue; + } + for (const [key, value] of Object.entries(node)) { + if (key === '$ref' && typeof value === 'string' && !value.startsWith('#')) { + throw invalid( + `OpenAPI document "${sourcePath}" contains an external reference "${value}". Only internal references (#/...) are supported; the importer performs no network or filesystem lookups`, + { sourcePath, ref: value }, + ); + } + queue.push(value); + } + } +} diff --git a/src/openapi/refs.ts b/src/openapi/refs.ts new file mode 100644 index 0000000..7fa3337 --- /dev/null +++ b/src/openapi/refs.ts @@ -0,0 +1,105 @@ +/** + * Lazy internal `$ref` resolution. + * + * The document is deliberately *not* dereferenced up front. A recursive schema + * (`Node.children[] -> Node`) expands without bound, so a whole-document + * dereference turns a 30 kB file into an out-of-memory kill — an importer that + * a merchant points at their own API must not be a way to do that. Instead + * each pointer is followed on demand, with the chain that led here carried + * along so a cycle is a diagnostic rather than a hang. + */ +import { CommerceError } from '../core/errors/index.js'; + +/** Bounds a pathological but non-cyclic chain of `$ref`s pointing at `$ref`s. */ +const MAX_REF_DEPTH = 100; + +export interface Dereferenced { + readonly value: unknown; + /** Pointers already followed, oldest first. Pass it back in to keep detecting cycles. */ + readonly stack: readonly string[]; +} + +export function isRefNode(value: unknown): value is { $ref: string } { + return ( + typeof value === 'object' && + value !== null && + typeof (value as { $ref?: unknown }).$ref === 'string' + ); +} + +/** + * Follows a chain of `$ref` nodes to the first value that is not one. + * + * Throws `CONFIG_INVALID` for a cycle, an unresolvable pointer, or an external + * reference — the last is refused at load time too, so reaching it here means + * a caller built a node the loader never saw. + */ +export function dereference( + document: Record, + node: unknown, + stack: readonly string[] = [], +): Dereferenced { + let current = node; + let chain = stack; + while (isRefNode(current)) { + const pointer = current.$ref; + if (!pointer.startsWith('#')) { + throw new CommerceError( + 'CONFIG_INVALID', + `OpenAPI document contains an external reference "${pointer}". Only internal references (#/...) are supported`, + { details: { ref: pointer } }, + ); + } + if (chain.includes(pointer)) { + throw new CommerceError( + 'CONFIG_INVALID', + `OpenAPI reference "${pointer}" is circular (${[...chain, pointer].join(' -> ')})`, + { details: { ref: pointer, chain: [...chain, pointer] } }, + ); + } + if (chain.length >= MAX_REF_DEPTH) { + throw new CommerceError( + 'CONFIG_INVALID', + `OpenAPI reference chain exceeds ${MAX_REF_DEPTH} hops at "${pointer}"`, + { details: { ref: pointer } }, + ); + } + current = resolvePointer(document, pointer); + chain = [...chain, pointer]; + } + return { value: current, stack: chain }; +} + +/** RFC 6901 JSON Pointer, rooted at the document (`#` or `#/a/b`). */ +function resolvePointer(document: Record, ref: string): unknown { + const pointer = ref.slice(1); + if (pointer === '' || pointer === '/') return document; + if (!pointer.startsWith('/')) { + throw new CommerceError( + 'CONFIG_INVALID', + `OpenAPI reference "${ref}" is not a JSON Pointer. Plain names and anchors are not supported`, + { details: { ref } }, + ); + } + let current: unknown = document; + for (const rawSegment of pointer.slice(1).split('/')) { + // `~1` before `~0`: the reverse order turns an escaped "~1" back into "/". + const segment = decodeURIComponent(rawSegment).replaceAll('~1', '/').replaceAll('~0', '~'); + if (Array.isArray(current)) { + const index = Number(segment); + current = Number.isInteger(index) ? current[index] : undefined; + } else if (typeof current === 'object' && current !== null) { + current = (current as Record)[segment]; + } else { + current = undefined; + } + if (current === undefined) { + throw new CommerceError( + 'CONFIG_INVALID', + `OpenAPI reference "${ref}" does not resolve to anything in the document`, + { details: { ref } }, + ); + } + } + return current; +} diff --git a/src/openapi/types.ts b/src/openapi/types.ts new file mode 100644 index 0000000..5d96e65 --- /dev/null +++ b/src/openapi/types.ts @@ -0,0 +1,52 @@ +/** + * Importer-internal types. + * + * Nothing here is part of the frozen contract, and nothing here may leak into + * `src/core`: OpenAPI is an import/config concern, so it terminates at the + * canonical resource/config boundary rather than travelling into the runtime. + */ +import type { BackendMethod } from '../core/domain/resource.js'; + +/** OpenAPI feature versions this importer understands. Patch level is ignored. */ +export type OpenApiVersion = '3.0' | '3.1' | '3.2'; + +export interface LoadedOpenApiDocument { + readonly version: OpenApiVersion; + /** The source document, verbatim. References are resolved lazily, never up front. */ + readonly document: Record; + readonly sourcePath: string; +} + +/** + * Structured import findings. + * + * Converters return these instead of printing: the CLI decides what a warning + * looks like and whether `--strict` turns one into a non-zero exit, and the + * tests can assert on codes rather than on console text. + */ +export interface ImportDiagnostic { + readonly severity: 'warning' | 'error'; + readonly code: string; + /** The resource id, or `METHOD path` when discovery failed before an id existed. */ + readonly operation?: string; + readonly message: string; +} + +/** One discovered OpenAPI operation, before schemas are converted (Phase 4). */ +export interface OpenApiOperationCandidate { + readonly resourceId: string; + readonly method: BackendMethod; + /** The OpenAPI path template, e.g. `/users/{userId}/orders`. */ + readonly path: string; + /** Selected server + path, with `{param}` templates preserved literally. */ + readonly backendUrl: string; + readonly operationId?: string; + readonly name: string; + readonly description?: string; + /** Path-item parameters first, then operation parameters — unresolved nodes. */ + readonly parameters: readonly unknown[]; + readonly requestBody?: unknown; + readonly responses?: unknown; + /** Effective security requirements (operation's own, else the document's). */ + readonly security: readonly unknown[]; +} diff --git a/tests/unit/cli/packaging.test.ts b/tests/unit/cli/packaging.test.ts index 48bb483..338ab59 100644 --- a/tests/unit/cli/packaging.test.ts +++ b/tests/unit/cli/packaging.test.ts @@ -138,11 +138,24 @@ describe('published package metadata', () => { expect(existsSync(resolve(pkgRoot, 'pnpm-lock.yaml'))).toBe(false); }); - it('pins zod through npm-native overrides', () => { - // two zod majors in one graph break `instanceof` across module - // boundaries. The pin moved twice (pnpm-workspace.yaml -> pnpm.overrides - // -> overrides) and each move could silently drop it. - expect(manifest.overrides?.['zod']).toBe(manifest.dependencies?.['zod']); + it('pins zod for the x402 packages, per package rather than repo-wide', () => { + // Two zod majors in one graph break `instanceof` across module + // boundaries, so every package that shares zod values with ours resolves + // to *our* zod. The pin moved three times (pnpm-workspace.yaml -> + // pnpm.overrides -> overrides -> per-package overrides) and each move + // could silently drop it. + // + // It is deliberately no longer repo-wide: `@scalar/openapi-parser` (the + // OpenAPI importer) needs zod 4 and gets its own nested copy. Nothing + // crosses that seam — the importer hands the parser plain JSON and gets + // plain JSON back — so the two majors never meet a shared `instanceof`. + const overrides = manifest.overrides as Record | undefined; + expect(overrides?.['zod']).toBeUndefined(); + for (const pkg of ['@x402/core', '@x402/evm', '@coinbase/x402']) { + expect((overrides?.[pkg] as Record | undefined)?.['zod']).toBe( + manifest.dependencies?.['zod'], + ); + } expect(manifest.pnpm).toBeUndefined(); }); diff --git a/tests/unit/openapi/discover.test.ts b/tests/unit/openapi/discover.test.ts new file mode 100644 index 0000000..e2b7daf --- /dev/null +++ b/tests/unit/openapi/discover.test.ts @@ -0,0 +1,142 @@ +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { isCommerceError } from '../../../src/core/errors/index.js'; +import { + type DiscoverOptions, + type DiscoveryResult, + discoverOperations, + loadOpenApiDocument, +} from '../../../src/openapi/index.js'; + +const fixture = (name: string): string => + join(fileURLToPath(new URL('./fixtures/', import.meta.url)), name); + +async function discover(name: string, options: DiscoverOptions = {}): Promise { + return discoverOperations(await loadOpenApiDocument(fixture(name)), options); +} + +const codes = (result: DiscoveryResult): string[] => result.diagnostics.map((d) => d.code); +const ids = (result: DiscoveryResult): string[] => result.operations.map((o) => o.resourceId); + +describe('discoverOperations', () => { + it('uses operationId, falls back to method_path, and normalises both', async () => { + const result = await discover('petstore-3.0.yaml'); + expect(ids(result)).toEqual(['listPets', 'post_pets', 'getPet']); + }); + + it('normalises an operationId that is not a legal resource id', async () => { + const result = await discover('minimal-3.1.json'); + // "list things!" — the space and "!" are not in the id character set. + expect(ids(result)).toEqual(['list_things']); + }); + + it('generates the same ids on every run (no counters, no iteration order)', async () => { + const first = await discover('petstore-3.0.yaml'); + const second = await discover('petstore-3.0.yaml'); + expect(ids(first)).toEqual(ids(second)); + }); + + it('warns about an unsupported HTTP method instead of coercing it', async () => { + const result = await discover('petstore-3.0.yaml'); + const head = result.diagnostics.find((d) => d.code === 'unsupported-method'); + expect(head?.operation).toBe('HEAD /pets/{petId}'); + expect(head?.severity).toBe('warning'); + }); + + it('fails the import when two operations claim one resource id', async () => { + try { + await discover('collision.yaml'); + expect.unreachable(); + } catch (error) { + expect(isCommerceError(error) && error.code).toBe('CONFIG_INVALID'); + if (isCommerceError(error)) { + expect(error.message).toContain('get_a_b'); + expect(error.message).toContain('GET /a/b'); + expect(error.message).toContain('GET /a-b'); + } + } + }); + + it('takes name from summary, then operationId, then the resource id', async () => { + const result = await discover('petstore-3.0.yaml'); + const [list, create, get] = result.operations; + expect(list?.name).toBe('List pets'); + expect(list?.description).toBe('Every pet, paged.'); + expect(create?.name).toBe('Create a pet'); + expect(get?.name).toBe('getPet'); + expect(get?.description).toBeUndefined(); + }); + + it('resolves the root server and keeps {param} templates literal', async () => { + const result = await discover('petstore-3.0.yaml'); + expect(result.operations.map((o) => o.backendUrl)).toEqual([ + 'https://api.example.com/v1/pets', + 'https://api.example.com/v1/pets', + 'https://api.example.com/v1/pets/{petId}', + ]); + }); + + it('prefers an operation server over a path-item server over the root', async () => { + const result = await discover('variables-3.2.yaml'); + const byId = Object.fromEntries(result.operations.map((o) => [o.resourceId, o.backendUrl])); + expect(byId['listReports']).toBe('https://reports.example.com/reports'); + expect(byId['createReport']).toBe('https://write.example.com/reports'); + expect(byId['health']).toBe('https://eu.api.example.com/v1/health'); + }); + + it('substitutes server-variable defaults and says that it did', async () => { + const result = await discover('variables-3.2.yaml'); + const warning = result.diagnostics.find((d) => d.code === 'server-variable-default'); + expect(warning?.message).toContain('region=eu'); + expect(warning?.message).toContain('stage=v1'); + }); + + it('lets --base-url override every declared server', async () => { + const result = await discover('variables-3.2.yaml', { baseUrl: 'http://localhost:3000/api/' }); + expect(result.operations.map((o) => o.backendUrl).sort()).toEqual([ + 'http://localhost:3000/api/health', + 'http://localhost:3000/api/reports', + 'http://localhost:3000/api/reports', + ]); + expect(codes(result)).not.toContain('server-variable-default'); + }); + + it('rejects a --base-url that is not an absolute http(s) URL', async () => { + await expect(discover('petstore-3.0.yaml', { baseUrl: '/v1' })).rejects.toThrowError(); + }); + + it('skips an operation whose only server URL is relative, and says to pass --base-url', async () => { + const result = await discover('relative-server.yaml'); + expect(result.operations).toHaveLength(0); + const diagnostic = result.diagnostics[0]; + expect(diagnostic?.code).toBe('relative-server-url'); + expect(diagnostic?.severity).toBe('error'); + expect(diagnostic?.message).toContain('--base-url'); + }); + + it('imports the same relative-server document once --base-url is supplied', async () => { + const result = await discover('relative-server.yaml', { baseUrl: 'https://api.example.com' }); + expect(result.operations.map((o) => o.backendUrl)).toEqual(['https://api.example.com/a']); + expect(result.diagnostics).toEqual([]); + }); + + it('collects path-item parameters before operation parameters', async () => { + const result = await discover('petstore-3.0.yaml'); + const get = result.operations.find((o) => o.resourceId === 'getPet'); + expect(get?.parameters).toEqual([ + { name: 'petId', in: 'path', required: true, schema: { type: 'string' } }, + ]); + const list = result.operations.find((o) => o.resourceId === 'listPets'); + expect(list?.parameters).toEqual([{ name: 'limit', in: 'query', schema: { type: 'integer' } }]); + }); + + it('carries the request body node unresolved, for the schema converter', async () => { + const result = await discover('petstore-3.0.yaml'); + const create = result.operations.find((o) => o.resourceId === 'post_pets'); + expect(create?.requestBody).toEqual({ + required: true, + content: { 'application/json': { schema: { $ref: '#/components/schemas/Pet' } } }, + }); + }); +}); diff --git a/tests/unit/openapi/fixtures/collision.yaml b/tests/unit/openapi/fixtures/collision.yaml new file mode 100644 index 0000000..a5e62f3 --- /dev/null +++ b/tests/unit/openapi/fixtures/collision.yaml @@ -0,0 +1,19 @@ +openapi: 3.1.0 +info: + title: Collision + version: 1.0.0 +servers: + - url: https://api.example.com +paths: + /a/b: + get: + operationId: 'get a/b' + responses: + '200': + description: ok + /a-b: + get: + operationId: 'get a b' + responses: + '200': + description: ok diff --git a/tests/unit/openapi/fixtures/cyclic-ref.yaml b/tests/unit/openapi/fixtures/cyclic-ref.yaml new file mode 100644 index 0000000..27d7f31 --- /dev/null +++ b/tests/unit/openapi/fixtures/cyclic-ref.yaml @@ -0,0 +1,26 @@ +openapi: 3.1.0 +info: + title: Cyclic + version: 1.0.0 +servers: + - url: https://api.example.com +paths: + /nodes: + get: + operationId: listNodes + responses: + '200': + description: ok +components: + schemas: + Node: + $ref: '#/components/schemas/Alias' + Alias: + $ref: '#/components/schemas/Node' + Tree: + type: object + properties: + children: + type: array + items: + $ref: '#/components/schemas/Tree' diff --git a/tests/unit/openapi/fixtures/external-file-ref.yaml b/tests/unit/openapi/fixtures/external-file-ref.yaml new file mode 100644 index 0000000..4c2b6da --- /dev/null +++ b/tests/unit/openapi/fixtures/external-file-ref.yaml @@ -0,0 +1,17 @@ +openapi: 3.1.0 +info: + title: External file + version: 1.0.0 +servers: + - url: https://api.example.com +paths: + /a: + get: + operationId: a + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: './common.yaml#/Thing' diff --git a/tests/unit/openapi/fixtures/external-http-ref.yaml b/tests/unit/openapi/fixtures/external-http-ref.yaml new file mode 100644 index 0000000..8d68a69 --- /dev/null +++ b/tests/unit/openapi/fixtures/external-http-ref.yaml @@ -0,0 +1,15 @@ +openapi: 3.1.0 +info: + title: External + version: 1.0.0 +servers: + - url: https://api.example.com +paths: + /a: + get: + operationId: a + parameters: + - $ref: 'https://example.com/common.yaml#/components/parameters/Limit' + responses: + '200': + description: ok diff --git a/tests/unit/openapi/fixtures/invalid.yaml b/tests/unit/openapi/fixtures/invalid.yaml new file mode 100644 index 0000000..2ce228a --- /dev/null +++ b/tests/unit/openapi/fixtures/invalid.yaml @@ -0,0 +1,5 @@ +openapi: 3.1.0 +info: + title: Broken + version: "1.0.0" +paths: {} diff --git a/tests/unit/openapi/fixtures/minimal-3.1.json b/tests/unit/openapi/fixtures/minimal-3.1.json new file mode 100644 index 0000000..db7f473 --- /dev/null +++ b/tests/unit/openapi/fixtures/minimal-3.1.json @@ -0,0 +1,10 @@ +{ + "openapi": "3.1.0", + "info": { "title": "Minimal", "version": "1.0.0" }, + "servers": [{ "url": "https://minimal.example.com" }], + "paths": { + "/things": { + "get": { "operationId": "list things!", "responses": { "200": { "description": "ok" } } } + } + } +} diff --git a/tests/unit/openapi/fixtures/not-openapi.yaml b/tests/unit/openapi/fixtures/not-openapi.yaml new file mode 100644 index 0000000..08eaa6b --- /dev/null +++ b/tests/unit/openapi/fixtures/not-openapi.yaml @@ -0,0 +1,3 @@ +openapi: 3.1.0 +paths: + /a: 5 diff --git a/tests/unit/openapi/fixtures/petstore-3.0.yaml b/tests/unit/openapi/fixtures/petstore-3.0.yaml new file mode 100644 index 0000000..c46dd3d --- /dev/null +++ b/tests/unit/openapi/fixtures/petstore-3.0.yaml @@ -0,0 +1,54 @@ +openapi: 3.0.3 +info: + title: Pet Store + version: 1.0.0 +servers: + - url: https://api.example.com/v1 +paths: + /pets: + get: + operationId: listPets + summary: List pets + description: Every pet, paged. + parameters: + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: ok + post: + summary: Create a pet + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + responses: + '201': + description: created + /pets/{petId}: + parameters: + - name: petId + in: path + required: true + schema: + type: string + get: + operationId: getPet + responses: + '200': + description: ok + head: + responses: + '200': + description: ok +components: + schemas: + Pet: + type: object + properties: + name: + type: string diff --git a/tests/unit/openapi/fixtures/relative-server.yaml b/tests/unit/openapi/fixtures/relative-server.yaml new file mode 100644 index 0000000..89fbbcd --- /dev/null +++ b/tests/unit/openapi/fixtures/relative-server.yaml @@ -0,0 +1,13 @@ +openapi: 3.1.0 +info: + title: Relative + version: 1.0.0 +servers: + - url: /v1 +paths: + /a: + get: + operationId: a + responses: + '200': + description: ok diff --git a/tests/unit/openapi/fixtures/swagger-2.0.yaml b/tests/unit/openapi/fixtures/swagger-2.0.yaml new file mode 100644 index 0000000..14a8b1d --- /dev/null +++ b/tests/unit/openapi/fixtures/swagger-2.0.yaml @@ -0,0 +1,5 @@ +swagger: '2.0' +info: + title: Legacy + version: 1.0.0 +paths: {} diff --git a/tests/unit/openapi/fixtures/variables-3.2.yaml b/tests/unit/openapi/fixtures/variables-3.2.yaml new file mode 100644 index 0000000..7300c3a --- /dev/null +++ b/tests/unit/openapi/fixtures/variables-3.2.yaml @@ -0,0 +1,33 @@ +openapi: 3.2.0 +info: + title: Variables + version: 1.0.0 +servers: + - url: https://{region}.api.example.com/{stage} + variables: + region: + default: eu + stage: + default: v1 +paths: + /reports: + servers: + - url: https://reports.example.com + get: + operationId: listReports + responses: + '200': + description: ok + post: + operationId: createReport + servers: + - url: https://write.example.com + responses: + '201': + description: ok + /health: + get: + operationId: health + responses: + '200': + description: ok diff --git a/tests/unit/openapi/load.test.ts b/tests/unit/openapi/load.test.ts new file mode 100644 index 0000000..2fcea5a --- /dev/null +++ b/tests/unit/openapi/load.test.ts @@ -0,0 +1,140 @@ +import { mkdtemp, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { isCommerceError } from '../../../src/core/errors/index.js'; +import { loadOpenApiDocument, MAX_SOURCE_BYTES } from '../../../src/openapi/index.js'; + +const FIXTURES = fileURLToPath(new URL('./fixtures/', import.meta.url)); +const fixture = (name: string): string => join(FIXTURES, name); + +async function expectConfigInvalid(load: Promise): Promise { + try { + await load; + expect.unreachable(); + } catch (error) { + expect(isCommerceError(error)).toBe(true); + if (!isCommerceError(error)) throw error; + expect(error.code).toBe('CONFIG_INVALID'); + return error.message; + } +} + +describe('loadOpenApiDocument', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('loads a 3.0 YAML document', async () => { + const loaded = await loadOpenApiDocument(fixture('petstore-3.0.yaml')); + expect(loaded.version).toBe('3.0'); + expect(loaded.sourcePath).toContain('petstore-3.0.yaml'); + // The document is kept verbatim — references are NOT expanded up front. + const paths = loaded.document['paths'] as Record< + string, + Record } }> + >; + const pet = paths['/pets']?.['post']?.requestBody.content['application/json']?.schema; + expect(pet).toEqual({ $ref: '#/components/schemas/Pet' }); + }); + + it('loads a 3.1 JSON document', async () => { + const loaded = await loadOpenApiDocument(fixture('minimal-3.1.json')); + expect(loaded.version).toBe('3.1'); + }); + + it('loads a 3.2 YAML document', async () => { + const loaded = await loadOpenApiDocument(fixture('variables-3.2.yaml')); + expect(loaded.version).toBe('3.2'); + }); + + it('rejects a directory', async () => { + const message = await expectConfigInvalid(loadOpenApiDocument(FIXTURES)); + expect(message).toContain('is a directory'); + }); + + it('rejects an unreadable path', async () => { + await expectConfigInvalid(loadOpenApiDocument(fixture('does-not-exist.yaml'))); + }); + + it('rejects an unsupported extension', async () => { + const dir = await mkdtemp(join(tmpdir(), 'oac-openapi-')); + const path = join(dir, 'spec.txt'); + await writeFile(path, 'openapi: 3.1.0\n'); + const message = await expectConfigInvalid(loadOpenApiDocument(path)); + expect(message).toContain('unsupported extension'); + }); + + it('rejects an empty document', async () => { + const dir = await mkdtemp(join(tmpdir(), 'oac-openapi-')); + const path = join(dir, 'spec.yaml'); + await writeFile(path, ' \n'); + const message = await expectConfigInvalid(loadOpenApiDocument(path)); + expect(message).toContain('is empty'); + }); + + it('rejects a document over the size limit before parsing it', async () => { + const dir = await mkdtemp(join(tmpdir(), 'oac-openapi-')); + const path = join(dir, 'huge.yaml'); + await writeFile(path, `openapi: 3.1.0\n# ${'x'.repeat(MAX_SOURCE_BYTES)}\n`); + const message = await expectConfigInvalid(loadOpenApiDocument(path)); + expect(message).toContain('over the'); + }); + + it('rejects invalid YAML', async () => { + const message = await expectConfigInvalid(loadOpenApiDocument(fixture('invalid.yaml'))); + expect(message).toContain('not valid YAML'); + }); + + it('rejects invalid JSON', async () => { + const dir = await mkdtemp(join(tmpdir(), 'oac-openapi-')); + const path = join(dir, 'spec.json'); + await writeFile(path, '{ "openapi": "3.1.0", }'); + const message = await expectConfigInvalid(loadOpenApiDocument(path)); + expect(message).toContain('not valid JSON'); + }); + + it('rejects a document that is not a valid OpenAPI description', async () => { + const message = await expectConfigInvalid(loadOpenApiDocument(fixture('not-openapi.yaml'))); + expect(message).toContain('not a valid OpenAPI'); + }); + + it('rejects Swagger 2.0 by name', async () => { + const message = await expectConfigInvalid(loadOpenApiDocument(fixture('swagger-2.0.yaml'))); + expect(message).toContain('Swagger 2.0'); + }); + + it('rejects an unsupported OpenAPI version', async () => { + const dir = await mkdtemp(join(tmpdir(), 'oac-openapi-')); + const path = join(dir, 'spec.yaml'); + await writeFile(path, 'openapi: 4.0.0\ninfo:\n title: t\n version: "1"\npaths: {}\n'); + const message = await expectConfigInvalid(loadOpenApiDocument(path)); + expect(message).toContain('not supported'); + }); + + it('rejects an external HTTP $ref without making a single outbound request', async () => { + const fetchSpy = vi.fn(async () => { + throw new Error('the importer must not perform network requests'); + }); + vi.stubGlobal('fetch', fetchSpy); + + const message = await expectConfigInvalid( + loadOpenApiDocument(fixture('external-http-ref.yaml')), + ); + expect(message).toContain('external reference'); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('rejects an external local-file $ref', async () => { + const message = await expectConfigInvalid( + loadOpenApiDocument(fixture('external-file-ref.yaml')), + ); + expect(message).toContain('./common.yaml#/Thing'); + }); + + it('accepts a document whose internal references are cyclic — resolution is lazy', async () => { + const loaded = await loadOpenApiDocument(fixture('cyclic-ref.yaml')); + expect(loaded.version).toBe('3.1'); + }); +}); diff --git a/tests/unit/openapi/refs.test.ts b/tests/unit/openapi/refs.test.ts new file mode 100644 index 0000000..2e0c222 --- /dev/null +++ b/tests/unit/openapi/refs.test.ts @@ -0,0 +1,73 @@ +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { isCommerceError } from '../../../src/core/errors/index.js'; +import { dereference, isRefNode, loadOpenApiDocument } from '../../../src/openapi/index.js'; + +const fixture = (name: string): string => + join(fileURLToPath(new URL('./fixtures/', import.meta.url)), name); + +function expectInvalid(run: () => unknown): string { + try { + run(); + expect.unreachable(); + } catch (error) { + expect(isCommerceError(error) && error.code).toBe('CONFIG_INVALID'); + return error instanceof Error ? error.message : String(error); + } +} + +describe('dereference', () => { + it('resolves an internal pointer', async () => { + const { document } = await loadOpenApiDocument(fixture('petstore-3.0.yaml')); + const { value, stack } = dereference(document, { $ref: '#/components/schemas/Pet' }); + expect(value).toEqual({ type: 'object', properties: { name: { type: 'string' } } }); + expect(stack).toEqual(['#/components/schemas/Pet']); + }); + + it('returns a non-reference node untouched', async () => { + const { document } = await loadOpenApiDocument(fixture('petstore-3.0.yaml')); + expect(dereference(document, { type: 'string' }).value).toEqual({ type: 'string' }); + expect(isRefNode({ type: 'string' })).toBe(false); + }); + + it('detects a reference cycle instead of hanging', async () => { + const { document } = await loadOpenApiDocument(fixture('cyclic-ref.yaml')); + const message = expectInvalid(() => + dereference(document, { $ref: '#/components/schemas/Node' }), + ); + expect(message).toContain('circular'); + }); + + it('detects a cycle reached through a caller-carried stack', async () => { + const { document } = await loadOpenApiDocument(fixture('cyclic-ref.yaml')); + const items = { $ref: '#/components/schemas/Tree' }; + const first = dereference(document, items); + expect(isRefNode(first.value)).toBe(false); + // Walking into Tree.properties.children.items reaches Tree again; the + // stack the caller carries is what turns that into a diagnostic. + expectInvalid(() => dereference(document, items, first.stack)); + }); + + it('rejects a pointer that resolves to nothing', async () => { + const { document } = await loadOpenApiDocument(fixture('petstore-3.0.yaml')); + const message = expectInvalid(() => + dereference(document, { $ref: '#/components/schemas/Missing' }), + ); + expect(message).toContain('does not resolve'); + }); + + it('rejects an external reference even when handed one directly', async () => { + const { document } = await loadOpenApiDocument(fixture('petstore-3.0.yaml')); + const message = expectInvalid(() => + dereference(document, { $ref: 'https://example.com/x.yaml#/Thing' }), + ); + expect(message).toContain('external reference'); + }); + + it('unescapes ~1 and ~0 in pointer segments', async () => { + const { document } = await loadOpenApiDocument(fixture('petstore-3.0.yaml')); + const { value } = dereference(document, { $ref: '#/paths/~1pets/get/operationId' }); + expect(value).toBe('listPets'); + }); +}); From 756cc021dcb8e96835dfa4df1a7a47260850764b Mon Sep 17 00:00:00 2001 From: Revinand Date: Thu, 3 Sep 2026 22:24:27 +0200 Subject: [PATCH 4/8] feat(openapi): normalize schemas and request inputs --- src/openapi/index.ts | 2 + src/openapi/request.ts | 339 ++++++++++++++++++ src/openapi/schema.ts | 272 ++++++++++++++ .../openapi/fixtures/request-shapes-3.1.yaml | 208 +++++++++++ tests/unit/openapi/fixtures/schemas-3.0.yaml | 87 +++++ tests/unit/openapi/request.test.ts | 246 +++++++++++++ tests/unit/openapi/schema.test.ts | 138 +++++++ 7 files changed, 1292 insertions(+) create mode 100644 src/openapi/request.ts create mode 100644 src/openapi/schema.ts create mode 100644 tests/unit/openapi/fixtures/request-shapes-3.1.yaml create mode 100644 tests/unit/openapi/fixtures/schemas-3.0.yaml create mode 100644 tests/unit/openapi/request.test.ts create mode 100644 tests/unit/openapi/schema.test.ts diff --git a/src/openapi/index.ts b/src/openapi/index.ts index f7972ad..eee66a1 100644 --- a/src/openapi/index.ts +++ b/src/openapi/index.ts @@ -6,6 +6,8 @@ export { type DiscoverOptions, type DiscoveryResult, discoverOperations } from './discover.js'; export { loadOpenApiDocument, MAX_SOURCE_BYTES } from './load.js'; export { dereference, isRefNode } from './refs.js'; +export { mapRequest, type RequestBindings, type RequestMapping } from './request.js'; +export { convertSchema, isPrimitiveSchema, type SchemaConversion } from './schema.js'; export type { ImportDiagnostic, LoadedOpenApiDocument, diff --git a/src/openapi/request.ts b/src/openapi/request.ts new file mode 100644 index 0000000..e1edf6c --- /dev/null +++ b/src/openapi/request.ts @@ -0,0 +1,339 @@ +/** + * OpenAPI parameters and request body -> a canonical input schema plus + * `backend.inputBindings`. + * + * Locations are namespaced (`path` / `query` / `body`) so a `?id=` and a + * `{id}` in the same operation cannot collide, and so the executor can source + * each group independently. Anything the executor would not send the way the + * API expects is refused rather than approximated: a required parameter we + * cannot represent skips the operation, an optional one is omitted with a + * warning. Approximating it would produce a resource that looks importable, + * takes payment, and then calls the backend wrongly. + */ +import type { JsonSchema } from '../core/domain/common.js'; +import { extractPathParameterNames } from '../core/execution/index.js'; +import { dereference } from './refs.js'; +import { convertSchema, isPrimitiveSchema } from './schema.js'; +import type { + ImportDiagnostic, + LoadedOpenApiDocument, + OpenApiOperationCandidate, +} from './types.js'; + +/** Top-level input property names. Also the binding values. */ +const PATH_GROUP = 'path'; +const QUERY_GROUP = 'query'; +const BODY_GROUP = 'body'; + +/** + * OpenAPI: parameters named these "SHALL be ignored" — they are transport + * concerns, and `Authorization` in particular is operator configuration that + * must never become an agent-supplied input. + */ +const IGNORED_HEADER_NAMES = new Set(['accept', 'content-type', 'authorization']); + +/** Serialization styles the executor's plain `key=value` query cannot produce. */ +const UNSUPPORTED_QUERY_STYLES = new Set(['deepObject', 'spaceDelimited', 'pipeDelimited']); + +export interface RequestBindings { + readonly path?: string; + readonly query?: string; + readonly body?: string; +} + +export type RequestMapping = + | { + readonly supported: true; + readonly inputSchema: JsonSchema; + readonly inputBindings: RequestBindings; + /** Set only for a vendor `+json` body, which needs a static Content-Type. */ + readonly contentType?: string; + /** Schema constraints dropped because the gateway does not enforce them. */ + readonly droppedKeywords: readonly string[]; + readonly diagnostics: readonly ImportDiagnostic[]; + } + | { readonly supported: false; readonly diagnostics: readonly ImportDiagnostic[] }; + +export function mapRequest( + loaded: LoadedOpenApiDocument, + candidate: OpenApiOperationCandidate, +): RequestMapping { + const { document } = loaded; + const diagnostics: ImportDiagnostic[] = []; + const dropped = new Set(); + const operation = candidate.resourceId; + + const warn = (code: string, message: string): void => { + diagnostics.push({ severity: 'warning', code, operation, message }); + }; + const skip = (code: string, message: string): RequestMapping => { + diagnostics.push({ + severity: 'error', + code, + operation, + message: `Skipped ${candidate.method} ${candidate.path}: ${message}`, + }); + return { supported: false, diagnostics }; + }; + + const pathProperties: Record = {}; + const queryProperties: Record = {}; + const requiredQuery: string[] = []; + const templateParams = new Set(extractPathParameterNames(candidate.path)); + + for (const parameter of mergeParameters(document, candidate.parameters)) { + const name = typeof parameter['name'] === 'string' ? parameter['name'] : undefined; + const location = typeof parameter['in'] === 'string' ? parameter['in'] : undefined; + if (name === undefined || location === undefined) continue; + const required = parameter['required'] === true || location === 'path'; + const label = `${location} parameter "${name}"`; + + if (location === 'header' || location === 'cookie') { + if (location === 'header' && IGNORED_HEADER_NAMES.has(name.toLowerCase())) continue; + if (required) { + return skip( + 'unsupported-required-parameter', + `${label} is required, and ${location} parameters are operator configuration in this release, not agent input`, + ); + } + warn( + 'unsupported-optional-parameter', + `Omitted optional ${label}: ${location} parameters are not imported. Configure it under backend.headers if the API needs it`, + ); + continue; + } + if (location !== 'path' && location !== 'query') { + if (required) return skip('unsupported-required-parameter', `${label} has unknown location`); + warn('unsupported-optional-parameter', `Omitted optional ${label}: unknown location`); + continue; + } + + const unsupported = describeUnsupportedParameter(document, parameter, location); + if (unsupported !== undefined) { + if (required) return skip('unsupported-required-parameter', `${label} ${unsupported}`); + warn('unsupported-optional-parameter', `Omitted optional ${label}: it ${unsupported}`); + continue; + } + + const converted = convertSchema(document, parameter['schema']); + if (!converted.supported) { + if (required) return skip('unsupported-required-parameter', `${label} ${converted.reason}`); + warn('unsupported-optional-parameter', `Omitted optional ${label}: ${converted.reason}`); + continue; + } + for (const keyword of converted.dropped) dropped.add(keyword); + + if (location === 'path') { + if (!templateParams.has(name)) { + warn( + 'path-parameter-not-in-template', + `Ignored path parameter "${name}": it does not appear in "${candidate.path}"`, + ); + continue; + } + pathProperties[name] = converted.schema; + } else { + queryProperties[name] = converted.schema; + if (required) requiredQuery.push(name); + } + } + + for (const param of templateParams) { + if (!Object.hasOwn(pathProperties, param)) { + return skip( + 'undeclared-path-parameter', + `"{${param}}" appears in the path but is not declared as a path parameter, so a caller could never supply it`, + ); + } + } + + const body = resolveBody(document, candidate); + if (body.kind === 'unsupported') { + if (body.required) return skip('unsupported-request-body', body.reason); + warn('unsupported-request-body', `Omitted the request body: ${body.reason}`); + } + if (body.kind === 'schema') { + for (const keyword of body.dropped) dropped.add(keyword); + } + + const properties: Record = {}; + const required: string[] = []; + const bindings: { path?: string; query?: string; body?: string } = {}; + + if (Object.keys(pathProperties).length > 0) { + properties[PATH_GROUP] = closedObject(pathProperties, Object.keys(pathProperties)); + // OpenAPI path parameters are always required, and a missing one makes the + // request unbuildable — which on a paid resource is payment with no + // delivery, so config rejects the shape at load time too. + required.push(PATH_GROUP); + bindings.path = PATH_GROUP; + } + if (Object.keys(queryProperties).length > 0) { + properties[QUERY_GROUP] = closedObject(queryProperties, requiredQuery); + if (requiredQuery.length > 0) required.push(QUERY_GROUP); + bindings.query = QUERY_GROUP; + } + if (body.kind === 'schema') { + properties[BODY_GROUP] = body.schema; + if (body.required) required.push(BODY_GROUP); + bindings.body = BODY_GROUP; + } + + return { + supported: true, + inputSchema: { + type: 'object', + properties, + ...(required.length > 0 ? { required } : {}), + additionalProperties: false, + }, + inputBindings: bindings, + ...(body.kind === 'schema' && body.contentType !== undefined + ? { contentType: body.contentType } + : {}), + droppedKeywords: [...dropped], + diagnostics, + }; +} + +/** + * Path Item parameters first, operation parameters second, with the OpenAPI + * identity rule: a parameter is the same one when `name` *and* `in` match, and + * the operation's own definition wins. + */ +function mergeParameters( + document: Record, + parameters: readonly unknown[], +): Record[] { + const byIdentity = new Map>(); + for (const raw of parameters) { + const resolved = dereference(document, raw).value; + if (typeof resolved !== 'object' || resolved === null || Array.isArray(resolved)) continue; + const parameter = resolved as Record; + byIdentity.set(`${String(parameter['in'])}:${String(parameter['name'])}`, parameter); + } + return [...byIdentity.values()]; +} + +/** + * Whether the executor can actually send this parameter the way the API reads + * it. It writes one `key=value` pair per query parameter and substitutes one + * URL-encoded value per path segment, so anything that serialises to several + * pairs or to a structured segment is out of scope for this release. + */ +function describeUnsupportedParameter( + document: Record, + parameter: Record, + location: 'path' | 'query', +): string | undefined { + if (parameter['content'] !== undefined) { + return 'uses the `content` form, whose media-type serialization the gateway does not perform'; + } + const style = parameter['style']; + if (location === 'query' && typeof style === 'string' && UNSUPPORTED_QUERY_STYLES.has(style)) { + return `uses style "${style}", which the gateway does not serialize`; + } + if (location === 'path' && typeof style === 'string' && style !== 'simple') { + return `uses style "${style}"; the gateway substitutes plain values only`; + } + const converted = convertSchema(document, parameter['schema']); + if (!converted.supported) return converted.reason; + if (!isPrimitiveSchema(converted.schema)) { + return 'is not a primitive; object and array parameters need serialization the gateway does not perform'; + } + return undefined; +} + +type BodyResult = + | { readonly kind: 'none' } + | { + readonly kind: 'schema'; + readonly schema: JsonSchema; + readonly required: boolean; + readonly contentType?: string; + readonly dropped: readonly string[]; + } + | { readonly kind: 'unsupported'; readonly required: boolean; readonly reason: string }; + +function resolveBody( + document: Record, + candidate: OpenApiOperationCandidate, +): BodyResult { + if (candidate.requestBody === undefined) return { kind: 'none' }; + const resolved = dereference(document, candidate.requestBody).value; + if (typeof resolved !== 'object' || resolved === null || Array.isArray(resolved)) { + return { kind: 'none' }; + } + const requestBody = resolved as Record; + const required = requestBody['required'] === true; + + if (candidate.method === 'GET' || candidate.method === 'DELETE') { + // The executor sends no body on these, and config refuses a body binding + // for them. Silently generating one would produce a resource whose + // payload never arrives. + return { + kind: 'unsupported', + required: false, + reason: `a ${candidate.method} request body is not sent by the gateway`, + }; + } + + const content = requestBody['content']; + if (!isRecord(content)) return { kind: 'none' }; + const mediaType = pickJsonMediaType(Object.keys(content)); + if (mediaType === undefined) { + return { + kind: 'unsupported', + required, + reason: `no JSON request body content type (found: ${Object.keys(content).join(', ') || 'none'}). Only application/json and application/*+json are supported — multipart and form data are never serialized as JSON`, + }; + } + const media = content[mediaType]; + const schemaNode = isRecord(media) ? media['schema'] : undefined; + if (schemaNode === undefined) { + // A body with no schema accepts anything; an open object is the honest + // representation and the loader closes nothing it was not told to close. + return { + kind: 'schema', + schema: { type: 'object', additionalProperties: true }, + required, + ...(mediaType === 'application/json' ? {} : { contentType: mediaType }), + dropped: [], + }; + } + const converted = convertSchema(document, schemaNode); + if (!converted.supported) return { kind: 'unsupported', required, reason: converted.reason }; + return { + kind: 'schema', + schema: converted.schema, + required, + ...(mediaType === 'application/json' ? {} : { contentType: mediaType }), + dropped: converted.dropped, + }; +} + +/** Exact `application/json` wins; otherwise the first `+json` in sorted order. */ +function pickJsonMediaType(keys: readonly string[]): string | undefined { + const normalised = keys.map((key) => ({ key, type: key.split(';')[0]?.trim().toLowerCase() })); + const exact = normalised.find((entry) => entry.type === 'application/json'); + if (exact !== undefined) return exact.key; + return [...normalised] + .sort((a, b) => (a.type ?? '').localeCompare(b.type ?? '')) + .find((entry) => entry.type?.startsWith('application/') && entry.type.endsWith('+json'))?.key; +} + +function closedObject( + properties: Record, + required: readonly string[], +): JsonSchema { + return { + type: 'object', + properties, + ...(required.length > 0 ? { required: [...required] } : {}), + additionalProperties: false, + }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/src/openapi/schema.ts b/src/openapi/schema.ts new file mode 100644 index 0000000..8c5ea0a --- /dev/null +++ b/src/openapi/schema.ts @@ -0,0 +1,272 @@ +/** + * OpenAPI schema -> the JSON Schema subset this gateway actually enforces. + * + * The honesty rule drives every decision here. `src/core`'s validator enforces + * `type`/`properties`/`required`/`additionalProperties`/`enum`/`items` and + * silently ignores everything else, so copying a `pattern` or a `oneOf` into a + * generated resource would advertise validation to agents that no code + * performs — and on a paid resource, the request the merchant's backend + * receives is the one the buyer already paid for. Unenforceable constraints + * are therefore dropped from the generated schema and reported, never + * carried along quietly. + */ +import type { JsonSchema } from '../core/domain/common.js'; +import { isCommerceError } from '../core/errors/index.js'; +import { dereference } from './refs.js'; + +/** Enforced by `compileJsonSchema`. Everything else is documentation at best. */ +const SUPPORTED_TYPES = new Set([ + 'object', + 'string', + 'number', + 'integer', + 'boolean', + 'array', + 'null', +]); + +/** Copied through: descriptive, never a constraint, so it cannot overstate. */ +const METADATA_KEYWORDS = ['title', 'description', 'default', 'example', 'examples', 'deprecated']; + +/** + * Annotations, not constraints: dropping them changes nothing a caller could + * observe, so they are not worth telling the operator about. Reporting them + * would bury the keywords that *do* matter (`pattern`, `minimum`, `format`) + * in noise, which is how a real warning gets ignored. + */ +const ANNOTATION_KEYWORDS = new Set([ + 'xml', + 'externalDocs', + 'readOnly', + 'writeOnly', + '$schema', + '$id', + '$comment', + '$defs', + 'definitions', +]); + +export type SchemaConversion = + | { + readonly supported: true; + readonly schema: JsonSchema; + /** Keyword names encountered and dropped, deduplicated, in first-seen order. */ + readonly dropped: readonly string[]; + } + | { readonly supported: false; readonly reason: string }; + +/** Types a path or query parameter may have: the executor stringifies one value. */ +export function isPrimitiveSchema(schema: JsonSchema): boolean { + const types = typeList(schema['type']); + if (types === undefined) return false; + return types.every((type) => type !== 'object' && type !== 'array' && SUPPORTED_TYPES.has(type)); +} + +export function convertSchema( + document: Record, + node: unknown, + stack: readonly string[] = [], +): SchemaConversion { + const dropped = new Set(); + try { + const schema = convertNode(document, node, stack, dropped); + return schema === undefined + ? { supported: false, reason: 'schema could not be represented' } + : { supported: true, schema, dropped: [...dropped] }; + } catch (error) { + if (error instanceof UnsupportedSchema) return { supported: false, reason: error.message }; + // A reference cycle or a dangling pointer arrives as CONFIG_INVALID from + // the resolver. For a *schema* that is a skip-this-operation condition, + // not a fail-the-whole-import one: the rest of the document is fine. + if (isCommerceError(error)) return { supported: false, reason: error.message }; + throw error; + } +} + +class UnsupportedSchema extends Error {} + +function convertNode( + document: Record, + node: unknown, + stack: readonly string[], + dropped: Set, +): JsonSchema | undefined { + // OpenAPI 3.1 allows boolean schemas: `true` accepts anything, `false` + // accepts nothing — and nothing is not a request shape we can generate. + if (node === true) return {}; + if (node === false) throw new UnsupportedSchema('schema is `false`, which accepts no value'); + + const resolved = dereference(document, node, stack); + const source = resolved.value; + if (typeof source !== 'object' || source === null || Array.isArray(source)) { + throw new UnsupportedSchema('schema node is not an object'); + } + const schemaNode = source as Record; + + if (Array.isArray(schemaNode['allOf'])) { + return mergeAllOf(document, schemaNode, resolved.stack, dropped); + } + for (const keyword of ['oneOf', 'anyOf', 'not', 'discriminator']) { + if (Object.hasOwn(schemaNode, keyword)) { + throw new UnsupportedSchema( + `schema uses "${keyword}", which this gateway cannot enforce — accepting it would advertise validation that never runs`, + ); + } + } + + const result: Record = {}; + const types = resolveTypes(schemaNode); + if (types !== undefined) result['type'] = types.length === 1 ? types[0] : types; + + for (const keyword of METADATA_KEYWORDS) { + if (Object.hasOwn(schemaNode, keyword)) result[keyword] = schemaNode[keyword]; + } + if (Array.isArray(schemaNode['enum'])) result['enum'] = [...schemaNode['enum']]; + + const properties = schemaNode['properties']; + if (isRecord(properties)) { + const converted: Record = {}; + for (const [name, sub] of Object.entries(properties)) { + const child = convertNode(document, sub, resolved.stack, dropped); + if (child !== undefined) converted[name] = child; + } + result['properties'] = converted; + if (!Object.hasOwn(schemaNode, 'additionalProperties')) result['additionalProperties'] = false; + } + + // Independent of `properties`: `required` without them is legal, and core's + // validator enforces it, so dropping it here would be a silent weakening. + const required = schemaNode['required']; + if (Array.isArray(required)) { + const names = required.filter((name): name is string => typeof name === 'string'); + if (names.length > 0) result['required'] = names; + } + + const additional = schemaNode['additionalProperties']; + if (typeof additional === 'boolean') { + result['additionalProperties'] = additional; + } else if (additional !== undefined) { + const child = convertNode(document, additional, resolved.stack, dropped); + if (child !== undefined) result['additionalProperties'] = child; + } + + const items = schemaNode['items']; + if (Array.isArray(items)) { + // Tuple `items` is not enforced; keeping it would look like it was. + dropped.add('items (tuple form)'); + } else if (items !== undefined) { + const child = convertNode(document, items, resolved.stack, dropped); + if (child !== undefined) result['items'] = child; + } + + for (const keyword of Object.keys(schemaNode)) { + if (isCarriedKeyword(keyword) || ANNOTATION_KEYWORDS.has(keyword) || keyword.startsWith('x-')) { + continue; + } + dropped.add(keyword); + } + + return result; +} + +function isCarriedKeyword(keyword: string): boolean { + return ( + keyword === 'type' || + keyword === 'properties' || + keyword === 'required' || + keyword === 'additionalProperties' || + keyword === 'enum' || + keyword === 'items' || + keyword === 'nullable' || + keyword === '$ref' || + METADATA_KEYWORDS.includes(keyword) + ); +} + +/** + * OpenAPI 3.0's `nullable: true` becomes draft-2020-12's union type, which is + * what the runtime validator understands. 3.1 already writes it that way. + */ +function resolveTypes(schema: Record): string[] | undefined { + const declared = typeList(schema['type']); + if (declared === undefined) return undefined; + const known = declared.filter((type) => SUPPORTED_TYPES.has(type)); + if (known.length === 0) { + throw new UnsupportedSchema(`schema type "${String(schema['type'])}" is not a JSON type`); + } + if (schema['nullable'] === true && !known.includes('null')) known.push('null'); + return known; +} + +function typeList(raw: unknown): string[] | undefined { + if (typeof raw === 'string') return [raw]; + if (Array.isArray(raw) && raw.every((entry) => typeof entry === 'string')) { + return raw as string[]; + } + return undefined; +} + +/** + * A simple `allOf` of object schemas is merged; anything else is refused. + * + * Merging is only safe while the branches agree — two branches declaring the + * same property differently have a meaning ("both must hold") that this + * validator cannot express, and picking one would quietly accept requests the + * API rejects, or reject ones it accepts. + */ +function mergeAllOf( + document: Record, + schema: Record, + stack: readonly string[], + dropped: Set, +): JsonSchema { + const branches = schema['allOf'] as readonly unknown[]; + const siblings = { ...schema }; + delete siblings['allOf']; + + const merged: Record = { type: 'object', properties: {}, required: [] }; + const properties = merged['properties'] as Record; + const required = new Set(); + + const parts = [...branches, ...(Object.keys(siblings).length > 0 ? [siblings] : [])]; + for (const branch of parts) { + const converted = convertNode(document, branch, stack, dropped); + if (converted === undefined) throw new UnsupportedSchema('allOf branch is empty'); + const types = typeList(converted['type']); + if (types !== undefined && !types.includes('object')) { + throw new UnsupportedSchema('allOf mixes object and non-object schemas'); + } + const branchProperties = converted['properties']; + if (isRecord(branchProperties)) { + for (const [name, sub] of Object.entries(branchProperties)) { + const existing = properties[name]; + if (existing !== undefined && JSON.stringify(existing) !== JSON.stringify(sub)) { + throw new UnsupportedSchema( + `allOf branches declare property "${name}" differently, which cannot be merged`, + ); + } + properties[name] = sub; + } + } + for (const name of Array.isArray(converted['required']) ? converted['required'] : []) { + if (typeof name === 'string') required.add(name); + } + for (const keyword of ['enum', 'items']) { + if (Object.hasOwn(converted, keyword)) { + throw new UnsupportedSchema(`allOf branch uses "${keyword}", which cannot be merged`); + } + } + if (typeof converted['description'] === 'string' && merged['description'] === undefined) { + merged['description'] = converted['description']; + } + } + + if (required.size > 0) merged['required'] = [...required]; + else delete merged['required']; + merged['additionalProperties'] = false; + return merged; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/tests/unit/openapi/fixtures/request-shapes-3.1.yaml b/tests/unit/openapi/fixtures/request-shapes-3.1.yaml new file mode 100644 index 0000000..31bda2a --- /dev/null +++ b/tests/unit/openapi/fixtures/request-shapes-3.1.yaml @@ -0,0 +1,208 @@ +openapi: 3.1.0 +info: + title: Request shapes + version: 1.0.0 +servers: + - url: https://api.example.com +paths: + /users/{userId}/orders: + parameters: + - name: userId + in: path + required: true + schema: + type: string + description: from the path item + - name: trace + in: query + schema: + type: string + post: + operationId: createOrder + parameters: + - name: userId + in: path + required: true + schema: + type: string + description: overridden by the operation + - name: notify + in: query + required: true + schema: + type: boolean + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + productId: + type: string + quantity: + type: integer + required: [productId] + responses: + '201': + description: ok + get: + operationId: listOrders + responses: + '200': + description: ok + /items/{itemId}: + get: + operationId: getItem + parameters: + - name: itemId + in: path + required: true + schema: + type: string + responses: + '200': + description: ok + /search: + get: + operationId: search + parameters: + - name: q + in: query + required: true + schema: + type: string + - name: page + in: query + schema: + type: integer + responses: + '200': + description: ok + /reports: + post: + operationId: createReport + requestBody: + content: + application/vnd.acme.report+json: + schema: + type: object + properties: + title: + type: string + application/xml: + schema: + type: object + responses: + '201': + description: ok + /uploads: + post: + operationId: upload + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + file: + type: string + format: binary + responses: + '201': + description: ok + /avatars: + post: + operationId: optionalUpload + requestBody: + content: + multipart/form-data: + schema: + type: object + responses: + '201': + description: ok + /tenants: + get: + operationId: listTenants + parameters: + - name: X-Tenant + in: header + required: true + schema: + type: string + responses: + '200': + description: ok + /profile: + get: + operationId: getProfile + parameters: + - name: X-Trace + in: header + schema: + type: string + - name: session + in: cookie + schema: + type: string + - name: Authorization + in: header + required: true + schema: + type: string + responses: + '200': + description: ok + /filters: + get: + operationId: listFilters + parameters: + - name: filter + in: query + required: true + style: deepObject + explode: true + schema: + type: object + responses: + '200': + description: ok + /optional-filters: + get: + operationId: listOptionalFilters + parameters: + - name: tags + in: query + schema: + type: array + items: + type: string + responses: + '200': + description: ok + /matrix/{id}: + get: + operationId: matrix + parameters: + - name: id + in: path + required: true + style: matrix + schema: + type: string + responses: + '200': + description: ok + /exports: + delete: + operationId: deleteExport + requestBody: + content: + application/json: + schema: + type: object + responses: + '204': + description: ok diff --git a/tests/unit/openapi/fixtures/schemas-3.0.yaml b/tests/unit/openapi/fixtures/schemas-3.0.yaml new file mode 100644 index 0000000..e6019af --- /dev/null +++ b/tests/unit/openapi/fixtures/schemas-3.0.yaml @@ -0,0 +1,87 @@ +openapi: 3.0.3 +info: + title: Schemas + version: 1.0.0 +servers: + - url: https://api.example.com +paths: + /noop: + get: + operationId: noop + responses: + '200': + description: ok +components: + schemas: + Primitive: + type: string + description: A name + default: anon + NullableString: + type: string + nullable: true + Enum: + type: string + enum: [draft, sent, paid] + Constrained: + type: object + properties: + email: + type: string + format: email + pattern: '^.+@.+$' + age: + type: integer + minimum: 0 + required: [email] + Nested: + type: object + properties: + owner: + $ref: '#/components/schemas/Constrained' + tags: + type: array + items: + type: string + additionalProperties: false + OpenMap: + type: object + additionalProperties: + type: number + Base: + type: object + properties: + id: + type: string + required: [id] + Extra: + type: object + properties: + label: + type: string + MergedAllOf: + allOf: + - $ref: '#/components/schemas/Base' + - $ref: '#/components/schemas/Extra' + description: Merged + ConflictingAllOf: + allOf: + - type: object + properties: + id: + type: string + - type: object + properties: + id: + type: integer + OneOf: + oneOf: + - type: string + - type: integer + Cyclic: + type: object + properties: + self: + $ref: '#/components/schemas/Cyclic' + RequiredOnly: + required: [id] diff --git a/tests/unit/openapi/request.test.ts b/tests/unit/openapi/request.test.ts new file mode 100644 index 0000000..67e93e7 --- /dev/null +++ b/tests/unit/openapi/request.test.ts @@ -0,0 +1,246 @@ +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { parseConfig } from '../../../src/config/schema.js'; +import { validateBackendRequestShape } from '../../../src/core/execution/index.js'; +import { + discoverOperations, + type LoadedOpenApiDocument, + loadOpenApiDocument, + mapRequest, + type RequestMapping, +} from '../../../src/openapi/index.js'; +import { validRawConfig } from '../config/fixtures.js'; + +const fixture = (name: string): string => + join(fileURLToPath(new URL('./fixtures/', import.meta.url)), name); + +let loaded: LoadedOpenApiDocument; +let mappings: Map; + +beforeAll(async () => { + loaded = await loadOpenApiDocument(fixture('request-shapes-3.1.yaml')); + const { operations } = discoverOperations(loaded); + mappings = new Map( + operations.map((operation) => [operation.resourceId, mapRequest(loaded, operation)]), + ); +}); + +function mapping(id: string): RequestMapping { + const found = mappings.get(id); + if (found === undefined) throw new Error(`no operation "${id}"`); + return found; +} + +function properties(id: string): Record> { + const result = mapping(id); + if (!result.supported) throw new Error(`operation "${id}" was skipped`); + return result.inputSchema['properties'] as Record>; +} + +const codes = (id: string): string[] => mapping(id).diagnostics.map((d) => d.code); + +describe('mapRequest', () => { + it('maps path + query + body into namespaced groups with bindings', () => { + const result = mapping('createOrder'); + expect(result.supported).toBe(true); + if (!result.supported) return; + + expect(result.inputSchema).toEqual({ + type: 'object', + properties: { + path: { + type: 'object', + properties: { userId: { type: 'string', description: 'overridden by the operation' } }, + required: ['userId'], + additionalProperties: false, + }, + query: { + type: 'object', + properties: { trace: { type: 'string' }, notify: { type: 'boolean' } }, + required: ['notify'], + additionalProperties: false, + }, + body: { + type: 'object', + properties: { productId: { type: 'string' }, quantity: { type: 'integer' } }, + required: ['productId'], + additionalProperties: false, + }, + }, + required: ['path', 'query', 'body'], + additionalProperties: false, + }); + expect(result.inputBindings).toEqual({ path: 'path', query: 'query', body: 'body' }); + expect(result.contentType).toBeUndefined(); + }); + + it('inherits path-item parameters and lets the operation override by name + in', () => { + const path = properties('createOrder')['path']; + // The path item declares "from the path item"; the operation wins. + expect(path).toMatchObject({ + properties: { userId: { description: 'overridden by the operation' } }, + }); + // "trace" comes only from the path item and still survives the merge. + expect(properties('createOrder')['query']?.['properties']).toHaveProperty('trace'); + }); + + it('maps a path-only operation', () => { + const result = mapping('getItem'); + expect(result.supported && result.inputBindings).toEqual({ path: 'path' }); + expect(Object.keys(properties('getItem'))).toEqual(['path']); + expect(result.supported && result.inputSchema['required']).toEqual(['path']); + }); + + it('maps path + optional query, requiring only the path group', () => { + const result = mapping('listOrders'); + expect(result.supported && result.inputBindings).toEqual({ path: 'path', query: 'query' }); + expect(result.supported && result.inputSchema['required']).toEqual(['path']); + }); + + it('maps a query-only operation and requires the group only when a member is required', () => { + const result = mapping('search'); + expect(result.supported && result.inputBindings).toEqual({ query: 'query' }); + expect(properties('search')['query']).toEqual({ + type: 'object', + properties: { q: { type: 'string' }, page: { type: 'integer' } }, + required: ['q'], + additionalProperties: false, + }); + expect(result.supported && result.inputSchema['required']).toEqual(['query']); + }); + + it('maps a body-only operation and keeps a vendor +json content type', () => { + const result = mapping('createReport'); + expect(result.supported).toBe(true); + if (!result.supported) return; + expect(result.inputBindings).toEqual({ body: 'body' }); + expect(result.contentType).toBe('application/vnd.acme.report+json'); + // An optional body does not make the group required. + expect(result.inputSchema['required']).toBeUndefined(); + }); + + it('skips an operation whose required body is multipart', () => { + const result = mapping('upload'); + expect(result.supported).toBe(false); + expect(codes('upload')).toContain('unsupported-request-body'); + expect(result.diagnostics[0]?.message).toContain('multipart/form-data'); + }); + + it('imports an operation whose optional body is multipart, without the body', () => { + const result = mapping('optionalUpload'); + expect(result.supported).toBe(true); + expect(result.supported && result.inputBindings).toEqual({}); + expect(codes('optionalUpload')).toContain('unsupported-request-body'); + }); + + it('skips an operation with a required header parameter', () => { + expect(mapping('listTenants').supported).toBe(false); + expect(codes('listTenants')).toContain('unsupported-required-parameter'); + }); + + it('omits optional header and cookie parameters with a warning, and ignores Authorization', () => { + const result = mapping('getProfile'); + expect(result.supported).toBe(true); + expect(result.diagnostics.map((d) => d.message).join(' ')).toContain('X-Trace'); + expect(result.diagnostics.map((d) => d.message).join(' ')).toContain('session'); + // A required Authorization header is operator configuration, not an input: + // the operation is imported rather than skipped. + expect(result.diagnostics.map((d) => d.message).join(' ')).not.toContain('Authorization'); + }); + + it('skips an operation with a required deepObject query parameter', () => { + expect(mapping('listFilters').supported).toBe(false); + expect(mapping('listFilters').diagnostics[0]?.message).toContain('deepObject'); + }); + + it('omits an optional array query parameter it cannot serialize', () => { + const result = mapping('listOptionalFilters'); + expect(result.supported).toBe(true); + expect(result.supported && result.inputBindings).toEqual({}); + expect(result.diagnostics[0]?.message).toContain('not a primitive'); + }); + + it('skips an operation whose {param} is never declared as a path parameter', () => { + // The OpenAPI validator rejects this document shape, so the candidate is + // built directly: the check exists because a `{param}` nothing can supply + // makes every call unservable — and a paid one settles first. + const result = mapRequest(loaded, { + resourceId: 'legacy', + method: 'GET', + path: '/legacy/{id}', + backendUrl: 'https://api.example.com/legacy/{id}', + name: 'legacy', + parameters: [], + security: [], + }); + expect(result.supported).toBe(false); + expect(result.diagnostics.map((d) => d.code)).toContain('undeclared-path-parameter'); + }); + + it('skips a path parameter using a style the executor cannot produce', () => { + expect(mapping('matrix').supported).toBe(false); + expect(mapping('matrix').diagnostics[0]?.message).toContain('matrix'); + }); + + it('omits a request body on a method that sends none', () => { + const result = mapping('deleteExport'); + expect(result.supported).toBe(true); + expect(result.supported && result.inputBindings).toEqual({}); + expect(result.diagnostics[0]?.message).toContain('not sent by the gateway'); + }); + + it('reports dropped schema constraints so the summary can list them', () => { + const constrained = mapRequest(loaded, { + resourceId: 'x', + method: 'POST', + path: '/x', + backendUrl: 'https://api.example.com/x', + name: 'x', + parameters: [], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { type: 'object', properties: { a: { type: 'string', pattern: '^a' } } }, + }, + }, + }, + security: [], + }); + expect(constrained.supported && constrained.droppedKeywords).toEqual(['pattern']); + }); + + it('produces a shape the config loader and the pre-payment check both accept', () => { + const result = mapping('createOrder'); + expect(result.supported).toBe(true); + if (!result.supported) return; + + const raw = validRawConfig(); + (raw['resources'] as Record) = { + create_order: { + name: 'Create order', + input: result.inputSchema, + backend: { + type: 'http', + method: 'POST', + url: 'https://api.example.com/users/{userId}/orders', + inputBindings: result.inputBindings, + }, + pricing: { type: 'free' }, + expose: ['http'], + }, + }; + const config = parseConfig(raw, {}); + const resource = config.resources[0]; + expect(resource?.handler.inputBindings).toEqual({ path: 'path', query: 'query', body: 'body' }); + expect(() => + validateBackendRequestShape( + // biome-ignore lint/style/noNonNullAssertion: asserted above + resource!.handler, + { path: { userId: 'u-1' }, query: { notify: true }, body: { productId: 'abc' } }, + { requestId: 'r', resourceId: 'create_order' }, + ), + ).not.toThrow(); + }); +}); diff --git a/tests/unit/openapi/schema.test.ts b/tests/unit/openapi/schema.test.ts new file mode 100644 index 0000000..b0b2ac4 --- /dev/null +++ b/tests/unit/openapi/schema.test.ts @@ -0,0 +1,138 @@ +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { convertSchema, loadOpenApiDocument } from '../../../src/openapi/index.js'; + +const fixture = (name: string): string => + join(fileURLToPath(new URL('./fixtures/', import.meta.url)), name); + +let document: Record; + +beforeAll(async () => { + document = (await loadOpenApiDocument(fixture('schemas-3.0.yaml'))).document; +}); + +const convert = (name: string) => convertSchema(document, { $ref: `#/components/schemas/${name}` }); + +describe('convertSchema', () => { + it('converts a primitive and keeps its metadata', () => { + const result = convert('Primitive'); + expect(result).toMatchObject({ + supported: true, + schema: { type: 'string', description: 'A name', default: 'anon' }, + }); + }); + + it('turns OpenAPI 3.0 nullable into a union type the validator understands', () => { + const result = convert('NullableString'); + expect(result.supported && result.schema['type']).toEqual(['string', 'null']); + }); + + it('keeps a 3.1 union type as written', () => { + const result = convertSchema(document, { type: ['string', 'null'] }); + expect(result.supported && result.schema['type']).toEqual(['string', 'null']); + }); + + it('keeps enum values', () => { + const result = convert('Enum'); + expect(result.supported && result.schema['enum']).toEqual(['draft', 'sent', 'paid']); + }); + + it('drops constraints the gateway does not enforce and names them', () => { + const result = convert('Constrained'); + expect(result.supported && [...result.dropped].sort()).toEqual([ + 'format', + 'minimum', + 'pattern', + ]); + const properties = result.supported + ? (result.schema['properties'] as Record>) + : {}; + expect(properties['email']).toEqual({ type: 'string' }); + expect(result.supported && result.schema['required']).toEqual(['email']); + }); + + it('resolves nested internal $refs and arrays', () => { + const result = convert('Nested'); + expect(result.supported && result.schema).toMatchObject({ + type: 'object', + properties: { + owner: { type: 'object', properties: { email: { type: 'string' } } }, + tags: { type: 'array', items: { type: 'string' } }, + }, + additionalProperties: false, + }); + }); + + it('closes an object schema that does not say otherwise', () => { + const result = convert('Base'); + expect(result.supported && result.schema['additionalProperties']).toBe(false); + }); + + it('keeps an additionalProperties subschema', () => { + const result = convert('OpenMap'); + expect(result.supported && result.schema['additionalProperties']).toEqual({ type: 'number' }); + }); + + it('drops tuple-form items rather than implying they are checked', () => { + // Tuple `items` is not legal OpenAPI 3.0, so it is converted directly + // rather than through a fixture the loader would reject. + const result = convertSchema(document, { + type: 'array', + items: [{ type: 'string' }, { type: 'integer' }], + }); + expect(result.supported && result.schema['items']).toBeUndefined(); + expect(result.supported && result.dropped).toContain('items (tuple form)'); + }); + + it('keeps required even when the schema declares no properties', () => { + const result = convert('RequiredOnly'); + expect(result.supported && result.schema['required']).toEqual(['id']); + }); + + it('merges a simple allOf of compatible object schemas', () => { + const result = convert('MergedAllOf'); + expect(result.supported && result.schema).toEqual({ + type: 'object', + properties: { id: { type: 'string' }, label: { type: 'string' } }, + required: ['id'], + additionalProperties: false, + description: 'Merged', + }); + }); + + it('refuses an allOf whose branches disagree about a property', () => { + const result = convert('ConflictingAllOf'); + expect(result.supported).toBe(false); + expect(!result.supported && result.reason).toContain('cannot be merged'); + }); + + it('refuses oneOf/anyOf/not rather than widening what is accepted', () => { + const result = convert('OneOf'); + expect(result.supported).toBe(false); + expect(!result.supported && result.reason).toContain('oneOf'); + expect(convertSchema(document, { anyOf: [{ type: 'string' }] }).supported).toBe(false); + expect(convertSchema(document, { not: { type: 'string' } }).supported).toBe(false); + }); + + it('reports a reference cycle instead of expanding it', () => { + const result = convert('Cyclic'); + expect(result.supported).toBe(false); + expect(!result.supported && result.reason).toContain('circular'); + }); + + it('accepts the 3.1 boolean schema `true` and refuses `false`', () => { + expect(convertSchema(document, true)).toMatchObject({ supported: true, schema: {} }); + expect(convertSchema(document, false).supported).toBe(false); + }); + + it('ignores annotations that are not constraints', () => { + const result = convertSchema(document, { + type: 'string', + readOnly: true, + xml: { name: 'a' }, + 'x-vendor': 1, + }); + expect(result.supported && result.dropped).toEqual([]); + }); +}); From d5cd9a37ecd8b4c7644386b2b8c1ab080c603e2e Mon Sep 17 00:00:00 2001 From: Revinand Date: Fri, 4 Sep 2026 00:15:16 +0200 Subject: [PATCH 5/8] feat(openapi): build agent commerce resource drafts --- README.md | 68 ++--- docs/contracts.md | 36 +-- src/config/schema.ts | 98 +++--- src/core/domain/resource.ts | 2 +- src/core/execution/backend-http.ts | 34 +-- src/openapi/discover.ts | 5 +- src/openapi/draft.ts | 288 ++++++++++++++++++ src/openapi/index.ts | 9 + src/openapi/load.ts | 4 +- src/openapi/refs.ts | 4 +- src/openapi/request.ts | 8 +- src/openapi/schema.ts | 13 +- src/openapi/types.ts | 4 +- tests/unit/cli/packaging.test.ts | 26 +- tests/unit/config/schema.test.ts | 50 +-- tests/unit/openapi/discover.test.ts | 2 +- tests/unit/openapi/draft.test.ts | 158 ++++++++++ .../unit/openapi/fixtures/responses-3.1.yaml | 116 +++++++ tests/unit/openapi/load.test.ts | 4 +- tests/unit/openapi/request.test.ts | 4 +- 20 files changed, 756 insertions(+), 177 deletions(-) create mode 100644 src/openapi/draft.ts create mode 100644 tests/unit/openapi/draft.test.ts create mode 100644 tests/unit/openapi/fixtures/responses-3.1.yaml diff --git a/README.md b/README.md index 2de6382..f52deae 100644 --- a/README.md +++ b/README.md @@ -16,12 +16,12 @@ ## What it is, in ten seconds You already have an HTTP API. AI agents want to **discover** it, **call** it and -**pay** for it — over protocols you did not write and do not want to maintain. +**pay** for it - over protocols you did not write and do not want to maintain. Agent Commerce Gateway sits in front of your existing API, in **your** infrastructure, and does that for you. You describe an endpoint in a YAML file; agents get an MCP tool and an x402 paywall. The money goes straight to your -wallet — the gateway never holds it, and never holds your keys. +wallet - the gateway never holds it, and never holds your keys. ```text Your existing API → Agent Commerce Gateway → AI Agent @@ -30,12 +30,12 @@ Your existing API → Agent Commerce Gateway → AI Agent ## Demo - ```text [agent] Discovering resources over MCP... -[agent] Found: market_report — Premium Market Report (0.01 USDC) +[agent] Found: market_report - Premium Market Report (0.01 USDC) [agent] Requesting resource... [gateway] Payment required: 0.01 USDC → 0x7099…79C8 @@ -55,7 +55,7 @@ Your existing API → Agent Commerce Gateway → AI Agent The dashboard at shows the same request as it happens. It polls the authenticated events route on a short interval rather than streaming: a browser `EventSource` cannot send the admin token, and the operator -routes are closed without one — so the SSE endpoint is reachable by a +routes are closed without one - so the SSE endpoint is reachable by a header-capable client, never by a browser. Polling is the dashboard's intended path, not a degraded mode. @@ -70,7 +70,7 @@ agent-commerce doctor Requires **Node >= 22**. One package ships two things: the `agent-commerce` CLI (`init`, `validate`, `doctor`, `demo`) and a library for embedding the gateway in your own process. A default install is ~65 MB and pulls no -blockchain or wallet dependencies at all — ~17 MB of that is the OpenAPI +blockchain or wallet dependencies at all - ~17 MB of that is the OpenAPI parser behind `import openapi`, which is a normal dependency because onboarding an existing API is the CLI's main job. @@ -87,10 +87,10 @@ const gateway = await createGateway({ const { url } = await gateway.listen(); ``` -### Optional peers — install only the rails you use +### Optional peers - install only the rails you use The MCP adapter and the x402 provider live on their own subpaths, because each -needs a dependency the rest of the package does not — the x402 rail brings the +needs a dependency the rest of the package does not - the x402 rail brings the whole EVM signing and RPC stack, which a gateway serving a free HTTP resource has no business installing. @@ -99,7 +99,7 @@ has no business installing. | gateway, config, receipts, CLI | `@devlab.group/agent-commerce` | `from '@devlab.group/agent-commerce'` | | expose resources as MCP tools | `+ @modelcontextprotocol/sdk` | `from '@devlab.group/agent-commerce/mcp'` | | accept x402 payments | `+ @x402/core @x402/evm viem` | `from '@devlab.group/agent-commerce/x402'` | -| authenticate to a CDP facilitator | `+ @coinbase/x402` | (no import — loaded on demand) | +| authenticate to a CDP facilitator | `+ @coinbase/x402` | (no import - loaded on demand) | ```bash npm install @devlab.group/agent-commerce @modelcontextprotocol/sdk @x402/core @x402/evm viem @@ -113,19 +113,19 @@ import { x402 } from '@devlab.group/agent-commerce/x402'; Peers are pinned exactly: x402's schemas and EIP-712 domains cross this boundary, so a version skew is a correctness problem rather than a convenience one. Import a subpath without its -peer installed and Node fails at load naming the missing package — deliberately, +peer installed and Node fails at load naming the missing package - deliberately, rather than starting a gateway that silently serves nothing. `@coinbase/x402` is the odd one out: it has no import of its own and is loaded dynamically, only when `facilitator.auth.type: cdp` is configured. It is worth -avoiding if you can — it brings `@coinbase/cdp-sdk` and `axios`, which carry +avoiding if you can - it brings `@coinbase/cdp-sdk` and `axios`, which carry high-severity advisories, while the package itself and the other three peers audit clean. `auth.type: bearer` covers any facilitator with a static token and installs nothing. ## Quickstart -Requirements: **Node >= 22**, **npm 10**, **Docker**. Nothing else — no API +Requirements: **Node >= 22**, **npm 10**, **Docker**. Nothing else - no API keys, no real money, no manual blockchain setup. ```bash @@ -149,7 +149,7 @@ Desktop on macOS and Windows translates permissions through its VM and does not need this. That is the whole thing. The stack is a private Anvil chain, a mock USDC token, -a demo merchant API, the gateway and a dashboard — all local and disposable. +a demo merchant API, the gateway and a dashboard - all local and disposable. To stop and wipe state: `docker compose down -v`. @@ -217,15 +217,15 @@ See [docs/configuration.md](docs/configuration.md). | **x402** | Supported | x402 v2 (`@x402/core`, `@x402/evm`), scheme `exact`, EVM | | **HTTP** | Supported | native routes | | **A2A** | Experimental | A2A v1.0.0, binding `JSONRPC`, method `SendMessage` | -| UCP | Planned | — | -| ACP · MPP · AP2 | Planned | — | +| UCP | Planned | - | +| ACP · MPP · AP2 | Planned | - | "Planned" means **no code ships for it**. "Experimental" means the code ships, -is tested against the official SDK, and serves a narrow named subset — A2A is +is tested against the official SDK, and serves a narrow named subset - A2A is off by default and documented in full at [docs/protocols.md](docs/protocols.md#a2a). Each adapter reports its own `supportedSpec`, `capabilities` and `unsupported` list at runtime via -`GET /.well-known/agent-commerce` and `agent-commerce doctor` — so the claim is +`GET /.well-known/agent-commerce` and `agent-commerce doctor` - so the claim is checkable, not marketing. Detail: [docs/protocols.md](docs/protocols.md). ## Payment model @@ -233,7 +233,7 @@ checkable, not marketing. Detail: [docs/protocols.md](docs/protocols.md). - **Non-custodial.** The gateway never holds funds, and never asks for a merchant or buyer private key. `payTo` is your address. - **Fail closed.** Missing, malformed, expired, replayed, wrong-amount, - wrong-recipient, wrong-network and wrong-asset payments all fail — each with a + wrong-recipient, wrong-network and wrong-asset payments all fail - each with a test. - **Replay-safe twice over.** EIP-3009 stops a double spend on-chain; the gateway additionally reserves a `replayKey` derived from the authorisation @@ -246,7 +246,7 @@ Detail: [docs/payment-flow.md](docs/payment-flow.md). ## Public networks -Same gateway, same pipeline — a different `network` and a facilitator that is +Same gateway, same pipeline - a different `network` and a facilitator that is not this process. No code changes, and no "live mode" to switch on. ### It has actually settled @@ -259,13 +259,13 @@ through a remote facilitator: | Base Sepolia | [`0xea41b234c4…`](https://sepolia.basescan.org/tx/0xea41b234c4645a4d335589ec9753646aa7cccd1b97e9e15823b88bff7b54a247) | | Base | [`0x57ec81c2a3…`](https://basescan.org/tx/0x57ec81c2a360d14d59a43cf4e24be09a6bd75cbe6185016372895bda73e42763) | -In both, the gateway held no key, signed nothing and paid no gas — the buyer +In both, the gateway held no key, signed nothing and paid no gas - the buyer signed an EIP-3009 authorisation offline holding no ETH, and the facilitator broadcast it. Each run reads the buyer and merchant balances and the transaction receipt back off the chain afterwards; the gateway's own report of success is not the proof. -Reproduce with `npm run test:testnet` / `npm run test:mainnet` — both spend +Reproduce with `npm run test:testnet` / `npm run test:mainnet` - both spend real funds, skip themselves without credentials, and never run in CI. ### The facilitator model @@ -280,7 +280,7 @@ gateway on a public network. | `remote` | an HTTP facilitator you point at | anywhere | With `remote`, the gateway holds **no signing key at all**. The buyer signs an -EIP-3009 authorisation offline — no ETH required — and the facilitator pays the +EIP-3009 authorisation offline - no ETH required - and the facilitator pays the gas. A facilitator cannot redirect your money: the authorisation names its recipient, amount and chain, so it can broadcast exactly that transfer or nothing. What it can do is see every authorisation you handle, and stop @@ -289,7 +289,7 @@ answering. Three auth types: `none`, `bearer` (a static token, installs nothing) and `cdp` (Coinbase Developer Platform, which signs a fresh JWT per request). Anything else is refused at config load rather than sent nothing. You can also run your -own — `remote` does not care who operates the endpoint. +own - `remote` does not care who operates the endpoint. ### Base Sepolia @@ -317,7 +317,7 @@ from [faucet.circle.com](https://faucet.circle.com); the buyer needs no ETH. chain. Chain id 84532 belongs to **both** Base Sepolia and this project's local dev -chain, deliberately. Nothing infers "public network" from it — `local`, +chain, deliberately. Nothing infers "public network" from it - `local`, `testnet` and `mainnet` are derived from the network *and* the facilitator together, and reported by `doctor`, `health()` and `/.well-known`. @@ -333,7 +333,7 @@ load, and the gateway will not start without them: | an HTTPS `facilitator.url` | | | `allowUnauthenticatedFacilitator: true` | only if that facilitator takes no credential | | a non-development `payTo` | | -| `asset` = USDC on Base, `assetName: "USD Coin"` | **not** `"USDC"` — that deployment predates the rename, and the buyer signs the name into their EIP-712 domain | +| `asset` = USDC on Base, `assetName: "USD Coin"` | **not** `"USDC"` - that deployment predates the rename, and the buyer signs the name into their EIP-712 domain | Full config in [`examples/base-mainnet/`](examples/base-mainnet/), and [`examples/base-mainnet-payai/`](examples/base-mainnet-payai/) for an @@ -341,9 +341,9 @@ unauthenticated facilitator. `npm run test:mainnet` proves it end to end and spends real USDC on every run. `agent-commerce validate` reports any of the above before anything starts, and -`doctor` prints `LIVE MAINNET MODE — REAL FUNDS`. +`doctor` prints `LIVE MAINNET MODE - REAL FUNDS`. -> Neither public-network suite runs in CI — there is no workflow and there must +> Neither public-network suite runs in CI - there is no workflow and there must > not be one. A workflow means a funded key in repository secrets, spendable by > anyone with write access. Both suites run from the machine that holds the > wallet, and skip themselves without credentials. @@ -353,13 +353,13 @@ spends real USDC on every run. ```console $ npm run agent-commerce -- doctor --config config-demo.yaml -PASS Config valid — 2 resource(s), merchant "Demo Data Store" (using local chain manifest .deploy/local.json for X402_ASSET, X402_ASSET_NAME, X402_ASSET_VERSION, X402_ASSET_DECIMALS, MERCHANT_WALLET, X402_FACILITATOR_PRIVATE_KEY) +PASS Config valid - 2 resource(s), merchant "Demo Data Store" (using local chain manifest .deploy/local.json for X402_ASSET, X402_ASSET_NAME, X402_ASSET_VERSION, X402_ASSET_DECIMALS, MERCHANT_WALLET, X402_FACILITATOR_PRIVATE_KEY) PASS Gateway healthy and ready at http://127.0.0.1:8080 PASS Backend 2/2 backend host(s) reachable PASS Protocols http=on mcp=on (/mcp) a2a=off INFO A2A disabled -PASS Payments x402 v2 (scheme=exact) enabled — LOCAL dev chain (eip155:84532, chain id shared with Base Sepolia), destination=0x7099…79C8, facilitator=local -INFO Payments (MPP) planned — not implemented in this release +PASS Payments x402 v2 (scheme=exact) enabled - LOCAL dev chain (eip155:84532, chain id shared with Base Sepolia), destination=0x7099…79C8, facilitator=local +INFO Payments (MPP) planned - not implemented in this release PASS Storage sqlite schema v1 writable; receipts=2 PASS Protocol versions reported by gateway /.well-known/agent-commerce @@ -368,7 +368,7 @@ Score: 7/7 checks passed That is real output, not an illustration. `doctor` also cross-checks the gateway's *live* settlement configuration against what your local config -resolves to, and fails if they disagree — a diagnostic that passes while the +resolves to, and fails if they disagree - a diagnostic that passes while the system is misconfigured is worse than none. Exits non-zero if anything fails. `--json` for machines. @@ -379,7 +379,7 @@ The demo binds everything to `127.0.0.1`. Before putting the gateway anywhere reachable by anyone else, know the split: - **Agent routes** (`/api/resources/:id/invoke`, `/mcp`) are unauthenticated by - design — paid resources are protected by payment, not by a password. + design - paid resources are protected by payment, not by a password. - **Operator routes** (`/api/receipts`, `/api/events`, `/api/events/stream`) are the merchant's commerce ledger: payer addresses, amounts, settlement hashes. They require `server.adminToken`, and return **404** if none is configured. @@ -403,11 +403,11 @@ See [CONTRIBUTING.md](CONTRIBUTING.md). ## Roadmap -**Now (v1.1.0)** — MCP, x402 v2, settlement on the local chain, Base Sepolia +**Now (v1.1.0)** - MCP, x402 v2, settlement on the local chain, Base Sepolia and Base mainnet, receipts, doctor, deterministic demo, and an experimental A2A v1.0.0 adapter. -**Next** — OpenAPI import · a stronger conformance suite · a `doctor` GitHub +**Next** - OpenAPI import · a stronger conformance suite · a `doctor` GitHub Action · UCP · MPP · ACP · AP2 · Shopify and WooCommerce examples · PostgreSQL · richer observability. diff --git a/docs/contracts.md b/docs/contracts.md index cbf4d52..ec6b15d 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -27,7 +27,7 @@ The cross-package contract is `src/core/public-types.ts`. | `COMMERCE_ERROR_CODES`, `COMMERCE_EVENT_TYPES`, `RETRYABLE_ERROR_CODES`, `DEFAULT_BACKEND_TIMEOUT_MS`, `isHttpProtocolAdapter`, `BackendMethod`, `CommerceErrorInfo`, `CommerceErrorOptions` | `errors/**`, `domain/**`, `interfaces/**` | everything | **The authoritative enumeration is [`contract-surface.txt`](contract-surface.txt)** -— 68 symbols, generated by `scripts/contract-surface.mjs` from the barrel +- 68 symbols, generated by `scripts/contract-surface.mjs` from the barrel itself and enforced by `npm run check:contract`. The table above groups them for orientation; it is written by hand and was found under-enumerating in round 6 (the whole `domain/wire.ts` group was missing). If the two ever disagree, @@ -41,7 +41,7 @@ the generated file is right and this table is stale. 2. `PaymentRequirement.challenge.accepts` is provider-native and opaque. Pass it through; do not reshape it. 3. `PaymentResult.replayKey` is derived **only** from the payment authorisation - (payer, nonce, asset, network) — never from the request id — so that the same + (payer, nonce, asset, network) - never from the request id - so that the same authorisation replayed against a different request still collides. 4. `PaymentProvider.verify` has no fund-moving side effects. Only `settle` moves money, and it runs only after a successful `verify` **and** a @@ -62,23 +62,23 @@ the generated file is right and this table is stale. - `payment-x402` adds `./testing.js` subpath (non-frozen); becomes the canonical import path for `readLocalChainManifest` - **Behaviour change (no type change):** `toCommerceError` no longer copies an arbitrary Error's `message` into the client-visible `message`. The original is kept on `cause`, which is never serialised. Found in the contract-freeze adversarial review. - **Type change:** `PaymentAttempt.status` gains `'settlement-uncertain'`, so a broadcast-but-unconfirmed settlement is no longer recorded as `failed`. -- `gateway` adds `./well-known.js` subpath (non-frozen, type-only: re-exports `WellKnownDocument`) so `demo/dashboard`'s hand-maintained mirror of the `/.well-known/agent-commerce` shape can assert assignability at compile time instead of silently drifting — the mirror had already drifted twice with nothing catching it (most recently `rpcUrl`). Not a stable public API; exists only to make the mirror verifiable. +- `gateway` adds `./well-known.js` subpath (non-frozen, type-only: re-exports `WellKnownDocument`) so `demo/dashboard`'s hand-maintained mirror of the `/.well-known/agent-commerce` shape can assert assignability at compile time instead of silently drifting - the mirror had already drifted twice with nothing catching it (most recently `rpcUrl`). Not a stable public API; exists only to make the mirror verifiable. - **Additive:** `GATEWAY_BUSY` error code (503, retryable). Load shedding is transient; the MCP queue-full path was throwing `PROTOCOL_UNSUPPORTED` (501, non-retryable), telling clients a throttle was a permanent capability gap. - **Additive:** `ReceiptStore.countUndeliveredReceipts`. A paid-but-undelivered purchase was indistinguishable from a successful one in every operator-facing view; the record was truthful but nothing read it. - **Additive:** `ReceiptStore.countReceipts`. Counting by list length saturated at the store's own list clamp, so `doctor` reported a frozen 500. -- **Additive:** `DELIVERY_SUMMARY_META_KEY` — the `_meta` key adapters attach the summary under. Frozen so producer and consumer cannot drift on the string. +- **Additive:** `DELIVERY_SUMMARY_META_KEY` - the `_meta` key adapters attach the summary under. Frozen so producer and consumer cannot drift on the string. - **Value change (no type change):** `DELIVERY_SUMMARY_META_KEY` is now `agent-commerce/delivery`. The wire identifiers were realigned with the `/.well-known/agent-commerce` route and the package name; safe only because no release exists yet for a client to have matched against. - **Additive:** `DeliverySummary` + `toDeliverySummary`. A payer is entitled to the record of their own purchase without reading the merchant's ledger. HTTP already sent one via the payment-response header; MCP sent nothing, which is why the demo buyer had to call the (now authenticated) `/api/receipts`. - **Value change + additive (x402 v2):** `PAYMENT_HEADER` is now `payment-signature` and `PAYMENT_RESPONSE_HEADER` is now `payment-response`, matching the x402 v2 HTTP binding; the v1 `x-payment` / `x-payment-response` pair is no longer accepted. New `PAYMENT_REQUIRED_HEADER` (`payment-required`) carries the base64 challenge on a 402. Wire-breaking by definition, and safe only because no release exists yet. -- **Additive:** `PaymentChallenge.envelope` and `PaymentRequiredEnvelope.payment.envelope` — the provider's own challenge document, verbatim (x402 v2's `PaymentRequired`). `accepts` is the offer list inside it; the envelope also carries the protocol version and the resource description that v1 kept per-requirement. Built once by the provider so the HTTP and MCP surfaces cannot describe different challenges. +- **Additive:** `PaymentChallenge.envelope` and `PaymentRequiredEnvelope.payment.envelope` - the provider's own challenge document, verbatim (x402 v2's `PaymentRequired`). `accepts` is the offer list inside it; the envelope also carries the protocol version and the resource description that v1 kept per-requirement. Built once by the provider so the HTTP and MCP surfaces cannot describe different challenges. - **Type change (x402, non-frozen surface):** `X402ProviderOptions.facilitator` is now `X402FacilitatorConfig`; `mode: 'remote'` gained a required `auth`, and `allowMainnet` was added. `mode: 'remote'` previously parsed but was rejected at config load and threw `PROTOCOL_UNSUPPORTED` at request time, so no working configuration changes shape. - **Additive:** `ProtocolName` gains `'a2a'`; config gains `protocols.a2a` (disabled by default, mount `/a2a`) and accepts `expose: [a2a]`. -- **Additive:** `AdapterHttpRoute` and the optional `HttpProtocolAdapter.additionalHttpRoutes`. A protocol whose specification pins a discovery URL outside the adapter's mount (A2A's `/.well-known/agent-card.json`) declares it instead of the gateway growing a per-protocol route conditional. Fixed routes get the mount's guarantees — unconsumed body, concurrency cap, failure isolation — and two adapters claiming one path is rejected before either starts. -- **Removed from the wire:** `/.well-known/agent-commerce` no longer publishes `payments.x402.facilitator.url`. A facilitator endpoint can carry a tenant path or an API key, exactly like `rpcUrl`, which the same route already withholds. It gained `payments.x402.mode` (`local` | `testnet` | `mainnet`) instead — chain id 84532 belongs to both the local dev chain and public Base Sepolia, so the network id alone cannot say which one a client is talking to. -- **Additive:** optional `BackendHandler.inputBindings` (`{ path?, query?, body? }`), naming the top-level input properties that carry each part of the backend request. *Use case:* `POST /users/{userId}/orders?notify=true` with a JSON body — path, query and body at once — which the leftover rule cannot express, because on a body-capable method everything not consumed by the URL template becomes the body. *Alternative considered:* infer the split from the input schema's property names; rejected, since the shape a merchant's backend expects is operator configuration, not something to guess from a schema, and guessing wrong on a paid resource is payment-without-delivery. *Compatibility:* absent means the legacy mapping, byte-for-byte; no consumer changes. When present, only named groups are forwarded — unmapped top-level input never reaches the backend. `validateBackendRequestShape` resolves both modes through the same function, so every shape error (missing/invalid path parameter, non-object group, query collision with the configured URL) is still an `INPUT_INVALID` raised before pricing. +- **Additive:** `AdapterHttpRoute` and the optional `HttpProtocolAdapter.additionalHttpRoutes`. A protocol whose specification pins a discovery URL outside the adapter's mount (A2A's `/.well-known/agent-card.json`) declares it instead of the gateway growing a per-protocol route conditional. Fixed routes get the mount's guarantees - unconsumed body, concurrency cap, failure isolation - and two adapters claiming one path is rejected before either starts. +- **Removed from the wire:** `/.well-known/agent-commerce` no longer publishes `payments.x402.facilitator.url`. A facilitator endpoint can carry a tenant path or an API key, exactly like `rpcUrl`, which the same route already withholds. It gained `payments.x402.mode` (`local` | `testnet` | `mainnet`) instead - chain id 84532 belongs to both the local dev chain and public Base Sepolia, so the network id alone cannot say which one a client is talking to. +- **Additive:** optional `BackendHandler.inputBindings` (`{ path?, query?, body? }`), naming the top-level input properties that carry each part of the backend request. *Use case:* `POST /users/{userId}/orders?notify=true` with a JSON body - path, query and body at once - which the leftover rule cannot express, because on a body-capable method everything not consumed by the URL template becomes the body. *Alternative considered:* infer the split from the input schema's property names; rejected, since the shape a merchant's backend expects is operator configuration, not something to guess from a schema, and guessing wrong on a paid resource is payment-without-delivery. *Compatibility:* absent means the legacy mapping, byte-for-byte; no consumer changes. When present, only named groups are forwarded - unmapped top-level input never reaches the backend. `validateBackendRequestShape` resolves both modes through the same function, so every shape error (missing/invalid path parameter, non-object group, query collision with the configured URL) is still an `INPUT_INVALID` raised before pricing. --- -# Integration contract — exact factory signatures +# Integration contract - exact factory signatures The gateway composition root (`src/gateway/main.ts`) wires the concrete implementations together. Every module below must export **exactly** these @@ -126,7 +126,7 @@ export interface X402ProviderOptions { readonly maxTimeoutSeconds?: number; /** * `local` runs the facilitator in this process and signs with an Anvil - * well-known key — LOCAL DEVELOPMENT ONLY — DO NOT FUND. `remote` calls an + * well-known key - LOCAL DEVELOPMENT ONLY - DO NOT FUND. `remote` calls an * HTTP facilitator, and this gateway then holds no signing key at all. */ readonly facilitator: X402FacilitatorConfig; @@ -239,8 +239,8 @@ export interface GatewayConfig { ## Gateway HTTP surface | Route | Purpose | | ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `GET /health` | liveness — always 200 when the process is up | -| `GET /ready` | readiness — 200 only when config, store, every required adapter **and every configured payment provider** are healthy (`fail` blocks; `warn` is degraded-but-serving) | +| `GET /health` | liveness - always 200 when the process is up | +| `GET /ready` | readiness - 200 only when config, store, every required adapter **and every configured payment provider** are healthy (`fail` blocks; `warn` is degraded-but-serving) | | `GET /.well-known/agent-commerce` | merchant + adapter descriptors, protocol/spec versions | | `GET /api/resources` | canonical resource list (no secrets) | | `POST /api/resources/:id/invoke` | HTTP protocol surface; `PAYMENT-SIGNATURE` header carries the proof; 402 + `PaymentRequiredEnvelope` body and `PAYMENT-REQUIRED` header when unpaid; `PAYMENT-RESPONSE` header on settlement | @@ -251,7 +251,7 @@ export interface GatewayConfig { ## Local chain deployment manifest `npm run chain:deploy` writes `.deploy/local.json` (git-ignored). Everything else -reads it — no hard-coded addresses anywhere else: +reads it - no hard-coded addresses anywhere else: ```json { @@ -275,7 +275,7 @@ Also available programmatically. There is exactly one implementation, in copy to drift. ```ts -// src/payments/x402/local-chain/manifest.ts — the one implementation +// src/payments/x402/local-chain/manifest.ts - the one implementation export interface LocalChainManifest { /* as above */ } export function readLocalChainManifest(cwd?: string): LocalChainManifest; // throws if absent @@ -287,7 +287,7 @@ export function readLocalChainManifest(cwd?: string): LocalChainManifest; // thr export const LOCAL_CHAIN_MANIFEST_PATH = '.deploy/local.json'; ``` -In-repo consumers — the demo agent and the E2E suite — import it through +In-repo consumers - the demo agent and the E2E suite - import it through `testing.ts`, by relative path: ```ts @@ -299,7 +299,7 @@ import { ``` `testing.ts` is test- and deploy-only. No public entry point re-exports it, so -a published consumer cannot import it — enforced by the module graph rather +a published consumer cannot import it - enforced by the module graph rather than by convention. The frozen provider surface is unchanged: `createX402PaymentProvider` and `createPaymentProof`. @@ -311,7 +311,7 @@ gateway never calls it and never holds a buyer key. ```ts // src/payments/x402/client.ts export interface CreatePaymentProofOptions { - /** Buyer's dev-only private key. LOCAL DEVELOPMENT ONLY — DO NOT FUND. */ + /** Buyer's dev-only private key. LOCAL DEVELOPMENT ONLY - DO NOT FUND. */ readonly buyerPrivateKey: `0x${string}`; readonly rpcUrl: string; /** One entry from PaymentRequiredEnvelope.payment.accepts, verbatim. */ @@ -333,5 +333,5 @@ export function createPaymentProof(options: CreatePaymentProofOptions): Promise< ## Composition root `src/gateway/main.ts` is the integration file, written once every factory -above exists. `createGateway` must be fully usable — and tested — with fakes, +above exists. `createGateway` must be fully usable - and tested - with fakes, without `main.ts`. diff --git a/src/config/schema.ts b/src/config/schema.ts index 00976a2..f42d988 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -5,7 +5,7 @@ * Two-phase validation: * 1. Zod validates *shape* (types, required fields, unknown-key rejection). * Numeric/boolean leaves accept either their native type or a string - * (env substitution always produces a string), and are left as-is here — + * (env substitution always produces a string), and are left as-is here - * Zod's typed transforms have surprising inference interactions with * `.strict()` objects, so numeric/boolean coercion is done explicitly, * in plain TypeScript, in the normalisation pass below. @@ -49,7 +49,7 @@ const ZERO_ADDRESS_PATTERN = /^0x0{40}$/i; * A resource `id` doubles as its MCP tool name (protocol-mcp registers one * tool per resource, named by id). Value verified against the regex actually * shipped in the installed `@modelcontextprotocol/sdk@1.30.0` - * (`shared/toolNameValidation.js`, SEP-986 "Specify Format for Tool Names") — + * (`shared/toolNameValidation.js`, SEP-986 "Specify Format for Tool Names") - * not duplicated as an SDK dependency, since config stays protocol-agnostic. */ const MCP_TOOL_NAME_PATTERN = /^[A-Za-z0-9._-]{1,128}$/; @@ -79,7 +79,7 @@ const ServerSchema = z adminToken: z.string().min(1).optional(), /** Browser origins allowed to read the dashboard-facing routes. Empty by default: closed. */ // Entries are matched literally against the browser's - // `Origin` header, so `"*"` and a trailing slash match nothing at all — + // `Origin` header, so `"*"` and a trailing slash match nothing at all - // fail-closed (a lockout, not a bypass), but silently, and a lockout with // no explanation is the kind of thing an operator "fixes" by disabling the // check. Reject those two shapes with a pointer instead. @@ -90,11 +90,11 @@ const ServerSchema = z .min(1) .refine((origin) => origin !== '*', { message: - 'wildcard "*" is not supported — allowedOrigins entries are matched literally against the browser\'s Origin header, so "*" would match nothing. List each scheme://host[:port] explicitly.', + 'wildcard "*" is not supported - allowedOrigins entries are matched literally against the browser\'s Origin header, so "*" would match nothing. List each scheme://host[:port] explicitly.', }) .refine((origin) => !origin.endsWith('/'), { message: - 'must not end with "/" — a browser Origin header never has a trailing slash, so this entry would match nothing. Use e.g. "http://localhost:5173".', + 'must not end with "/" - a browser Origin header never has a trailing slash, so this entry would match nothing. Use e.g. "http://localhost:5173".', }), ) .optional(), @@ -135,7 +135,7 @@ const RESERVED_GATEWAY_PATHS = [ /** * `mountPath` reaches Fastify as a route pattern, and a bad one throws inside - * route registration — deferred to `server.ready()`, so `createGateway` fails + * route registration - deferred to `server.ready()`, so `createGateway` fails * wholesale with an opaque `FST_ERR_*` instead of the adapter alone degrading. * Catching the shape here turns that into a CONFIG_INVALID naming the value. * Fastify pattern syntax (`:param`, `*`) is rejected rather than supported: @@ -145,11 +145,11 @@ const MountPathSchema = z .string() .min(1) .refine((value) => value.startsWith('/'), { - message: 'must start with "/" — it is an absolute gateway path, e.g. "/mcp".', + message: 'must start with "/" - it is an absolute gateway path, e.g. "/mcp".', }) .refine((value) => !/[:*?\s]/.test(value), { message: - 'must not contain ":", "*", "?" or whitespace — the mount is a literal path prefix, not a Fastify route pattern, and registers its own wildcard.', + 'must not contain ":", "*", "?" or whitespace - the mount is a literal path prefix, not a Fastify route pattern, and registers its own wildcard.', }) .refine( (value) => { @@ -278,7 +278,7 @@ const FacilitatorSchema = z.discriminatedUnion('mode', [ .object({ mode: z.literal('remote'), url: z.string().min(1), - // Absent means "this facilitator takes no credential" — an explicit + // Absent means "this facilitator takes no credential" - an explicit // statement, normalised to `{ type: 'none' }` below. On a mainnet that // combination is refused outright, so the default can never quietly // become an unauthenticated production facilitator. @@ -299,7 +299,7 @@ const X402Schema = z payTo: z.string().min(1), maxTimeoutSeconds: NumberOrString, facilitator: FacilitatorSchema, - /** Real funds. Never defaulted — see src/payments/x402/guardrails.ts. */ + /** Real funds. Never defaulted - see src/payments/x402/guardrails.ts. */ allowMainnet: BooleanOrString.optional(), /** Accepts a mainnet facilitator that takes no credential. Never defaulted. */ allowUnauthenticatedFacilitator: BooleanOrString.optional(), @@ -328,7 +328,7 @@ type RawConfig = z.infer; type RawResourceEntry = z.infer; // --------------------------------------------------------------------------- -// Public shape (docs/contracts.md — exact). +// Public shape (docs/contracts.md - exact). // --------------------------------------------------------------------------- export interface GatewayConfig { @@ -428,7 +428,7 @@ function describeIssue(issue: ZodIssue): { path: string; message: string; code: } // --------------------------------------------------------------------------- -// Numeric / boolean coercion (explicit, not via Zod — see file header). +// Numeric / boolean coercion (explicit, not via Zod - see file header). // --------------------------------------------------------------------------- function toNumber( @@ -436,7 +436,7 @@ function toNumber( path: string, bounds: { min?: number; max?: number } = {}, ): number { - // `Number('')` is 0 — finite, integral, and inside + // `Number('')` is 0 - finite, integral, and inside // `server.port`'s deliberate `min: 0` ("let the OS pick"). So `port: ${PORT:-}` // or an exported-but-empty PORT validated PASS and the gateway bound a random // port, after which `doctor` derived `http://127.0.0.1:0`, failed to connect, @@ -635,7 +635,7 @@ function validateMountPaths(protocols: NormalisedProtocols): void { if (baseA === baseB || baseA.startsWith(`${baseB}/`) || baseB.startsWith(`${baseA}/`)) { throw new CommerceError( 'CONFIG_INVALID', - `protocols.${nameA}.mountPath ("${a.mountPath}") collides with protocols.${nameB}.mountPath ("${b.mountPath}") — each mount registers a wildcard, so overlapping prefixes cannot both be served`, + `protocols.${nameA}.mountPath ("${a.mountPath}") collides with protocols.${nameB}.mountPath ("${b.mountPath}") - each mount registers a wildcard, so overlapping prefixes cannot both be served`, { details: { path: `protocols.${nameA}.mountPath` } }, ); } @@ -796,22 +796,22 @@ function normaliseResource( } /** - * `{param}` templates (e.g. `.../weather/{city}`) parse fine as a URL — the - * WHATWG parser just percent-encodes the braces — so this only rejects + * `{param}` templates (e.g. `.../weather/{city}`) parse fine as a URL - the + * WHATWG parser just percent-encodes the braces - so this only rejects * genuinely malformed strings and non-http(s) schemes, not templating. */ /** * Permissive-by-default is the wrong default for a value forwarded to * someone else's API: an object schema that * omits `additionalProperties` defaults, per JSON Schema itself, to - * "anything goes" — so default it to `false` here instead, unless the + * "anything goes" - so default it to `false` here instead, unless the * operator set it explicitly (including explicitly to `true`, which is * respected). * * An earlier fix only stamped the ROOT schema, so * `filter: { type: object }` *looked* closed (the top level really was) * while every key one level down under `properties.filter` still passed - * verbatim — `core`'s validator (execution/validation.ts) only enforces + * verbatim - `core`'s validator (execution/validation.ts) only enforces * `additionalProperties` where the schema states it explicitly, at every * level independently. Recurse into `properties` and `items` the same way * `validateResourceSchemaKeywords` below already does, so "closed" actually @@ -819,7 +819,7 @@ function normaliseResource( * write `additionalProperties: false` on. * * `type` can be an array (`["object","null"]`, valid JSON - * Schema) — a bare `=== 'object'` string comparison missed it, so a schema + * Schema) - a bare `=== 'object'` string comparison missed it, so a schema * in that shape got stamped as open. `declaresObjectType` below is the same * check `core`'s validator now makes (`execution/validation.ts`'s * `isObjectSchemaNode`) so the two stay in agreement. @@ -827,7 +827,7 @@ function normaliseResource( function defaultClosedObjectSchema(schema: Record): Record { // imported, never re-implemented. The local predicate this // replaces recognised `type`/`properties` but not `required`, while core's - // validator recognised `properties`/`required` — so `input: { required: [q] }` + // validator recognised `properties`/`required` - so `input: { required: [q] }` // was an object to the validator and not to the stamper, and every unknown // key sailed through to the merchant backend. One definition, one drift. const isObjectSchema = isObjectSchemaNode(schema); @@ -856,7 +856,7 @@ function defaultClosedObjectSchema(schema: Record): Record): Record = { @@ -886,7 +886,7 @@ const EMPTY_CLOSED_OBJECT_SCHEMA: Record = { /** * `src/core`'s validator only enforces the subset documented in * `execution/validation.ts` (type/properties/required/additionalProperties/ - * enum/items) — everything else (`pattern`, `minLength`, `format`, `oneOf`, + * enum/items) - everything else (`pattern`, `minLength`, `format`, `oneOf`, * …) is silently ignored at runtime. An operator who writes `pattern` and * never sees it enforced has no way to know that from the config alone, so * warn at load time instead of letting them find out the hard way. @@ -918,8 +918,8 @@ function isPlainObject(value: unknown): value is Record { } /** `type` explicitly set to something that rules out "object" (a bare - * value, or an array not containing "object"). `undefined` — no `type` at - * all — is deliberately NOT treated as excluding: core's validator treats + * value, or an array not containing "object"). `undefined` - no `type` at + * all - is deliberately NOT treated as excluding: core's validator treats * `properties`/`required` with no `type` as an object schema, so that shape * is fine, not another instance of this bug. */ function excludesObjectType(schema: Record): boolean { @@ -939,7 +939,7 @@ function excludesObjectType(schema: Record): boolean { * `type` that actively rules "object" out while `properties`/`required` * imply it was meant to be one, e.g. a copy-pasted sibling schema whose * `type` was never updated. Rejecting (not just warning) here is the - * decision — a warning is exactly what let this bug class ship silently in + * decision - a warning is exactly what let this bug class ship silently in * the first place (`validate`/`doctor` both said PASS). */ function validateResourceSchemaKeywords( @@ -961,14 +961,14 @@ function validateResourceSchemaKeywords( ) { throw new CommerceError( 'CONFIG_INVALID', - `Resource "${id}" ${path} declares "properties" and/or "required" but its "type" (${JSON.stringify(schema['type'])}) does not include "object" — the validator will never route a value through either check under that type, so this schema claims to constrain input it does not actually enforce. Remove "properties"/"required", or include "object" in "type".`, + `Resource "${id}" ${path} declares "properties" and/or "required" but its "type" (${JSON.stringify(schema['type'])}) does not include "object" - the validator will never route a value through either check under that type, so this schema claims to constrain input it does not actually enforce. Remove "properties"/"required", or include "object" in "type".`, { details: { path: `resources.${id}.${path}`, resourceId: id } }, ); } // the other half. Closing a `required`-only node is // correct JSON Schema and quietly unsatisfiable: `required: ["q"]` demands a // property that `properties` never declares, so the stamped - // `additionalProperties: false` rejects `q` as an unknown key — the schema + // `additionalProperties: false` rejects `q` as an unknown key - the schema // can never be satisfied by any input at all. Fail-closed, so no money is at // risk, but every call would 400 with a config that loaded cleanly. Say so // at load instead. Only when the node really will be closed: an explicit @@ -1007,12 +1007,12 @@ function validateResourceSchemaKeywords( } else if (Array.isArray(items)) { // tuple-form `items` (an array of per-position // schemas) is valid JSON Schema, but `core`'s validator only supports - // the single-schema form applied to every element — a tuple silently + // the single-schema form applied to every element - a tuple silently // enforces nothing, invisibly rather than wrongly, so warn the same way // an unsupported keyword does. // eslint-disable-next-line no-console console.warn( - `[agent-commerce] resource "${id}" ${path}.items is a tuple (an array of schemas), which this gateway does not enforce — only a single schema applied to every array element is supported (see src/core/execution/validation.ts). Each position's schema is unenforced; treat it as documentation only.`, + `[agent-commerce] resource "${id}" ${path}.items is a tuple (an array of schemas), which this gateway does not enforce - only a single schema applied to every array element is supported (see src/core/execution/validation.ts). Each position's schema is unenforced; treat it as documentation only.`, ); } } @@ -1021,7 +1021,7 @@ function validateResourceSchemaKeywords( * `amount: z.string().min(1)` alone let "0,01", "$0.01", "1e-2", "-1" and an * over-precise "0.0000001" all pass config + `doctor`, then throw on every * purchase. A plain - * positive decimal only — no currency symbol, no thousands separator, no + * positive decimal only - no currency symbol, no thousands separator, no * exponent notation. */ const PRICING_AMOUNT_PATTERN = /^\d+(?:\.\d+)?$/; @@ -1032,16 +1032,16 @@ function validatePricingAmount(id: string, amount: string, x402: NormalisedX402 if (!PRICING_AMOUNT_PATTERN.test(amount)) { throw new CommerceError( 'CONFIG_INVALID', - `Resource "${id}" has pricing.amount "${amount}", which is not a plain positive decimal (no currency symbol, no thousands separator, no exponent — e.g. "0.01")`, + `Resource "${id}" has pricing.amount "${amount}", which is not a plain positive decimal (no currency symbol, no thousands separator, no exponent - e.g. "0.01")`, { details: { path, resourceId: id } }, ); } if (ZERO_AMOUNT_PATTERN.test(amount)) { // A zero-priced "paid" resource settles a zero-value transfer, which is - // nonsense — if something is free, it should say so. + // nonsense - if something is free, it should say so. throw new CommerceError( 'CONFIG_INVALID', - `Resource "${id}" has pricing.amount "0" — a paid resource cannot cost zero; use "pricing: { type: free }" instead`, + `Resource "${id}" has pricing.amount "0" - a paid resource cannot cost zero; use "pricing: { type: free }" instead`, { details: { path, resourceId: id } }, ); } @@ -1062,7 +1062,7 @@ function validatePricingAmount(id: string, amount: string, x402: NormalisedX402 * that makes those names mean something at load time rather than at the first * paid call. Input schemas are closed by default at every depth * (`defaultClosedObjectSchema`), so a binding naming a property the schema - * never declares can never be satisfied — the group would be silently empty + * never declares can never be satisfied - the group would be silently empty * on every request, which on a paid resource is payment for a request the * backend receives incomplete. * @@ -1090,18 +1090,18 @@ function validateInputBindings( (entry): entry is [string, string] => entry[1] !== undefined, ); if (entries.length === 0) { - fail('has an empty backend.inputBindings — remove the block to use the default mapping'); + fail('has an empty backend.inputBindings - remove the block to use the default mapping'); } if (backend.method === 'GET' || backend.method === 'DELETE') { if (bindings.body !== undefined) { fail( - `binds a request body on a ${backend.method}, which sends none — the value would be silently dropped`, + `binds a request body on a ${backend.method}, which sends none - the value would be silently dropped`, ); } } if (templated && bindings.path === undefined) { fail( - 'has backend.url path parameters but no "path" binding — in explicit binding mode nothing else supplies them, so every call would fail to reach the backend', + 'has backend.url path parameters but no "path" binding - in explicit binding mode nothing else supplies them, so every call would fail to reach the backend', ); } @@ -1131,7 +1131,7 @@ function validateInputBindings( if (!Object.hasOwn(properties, property)) { fail( - `binds "${location}" to input property "${property}", which the input schema does not declare — the schema is closed, so a caller could never supply it`, + `binds "${location}" to input property "${property}", which the input schema does not declare - the schema is closed, so a caller could never supply it`, { location, property }, ); } @@ -1140,7 +1140,7 @@ function validateInputBindings( const declared = properties[property]; if (location !== 'body' && isPlainObject(declared) && !isObjectSchemaNode(declared)) { fail( - `binds "${location}" to input property "${property}", which is not an object schema — ${location} parameters are read as an object of name/value pairs`, + `binds "${location}" to input property "${property}", which is not an object schema - ${location} parameters are read as an object of name/value pairs`, { location, property }, ); } @@ -1148,7 +1148,7 @@ function validateInputBindings( if (templated && bindings.path !== undefined && !required.has(bindings.path)) { fail( - `binds path parameters to input property "${bindings.path}" without listing it in the input schema's "required" — a caller that omits it cannot supply any path parameter, so the request could never be built`, + `binds path parameters to input property "${bindings.path}" without listing it in the input schema's "required" - a caller that omits it cannot supply any path parameter, so the request could never be built`, { property: bindings.path }, ); } @@ -1172,13 +1172,13 @@ function pickDefined>( /** * The root-cause half. `validateBackendRequestShape` - * (src/core) rejects a missing path parameter at request time — after + * (src/core) rejects a missing path parameter at request time - after * schema validation but, without this check, on every single call, because * a schema that never declares `{city}` can never satisfy it. A paid * resource in that shape settles the buyer's payment and then always fails * to reach the backend: no refund, replay key burned, on every call, not an * unlucky one. Every `{param}` in `backend.url` must be BOTH declared in - * `properties` AND listed in `required` — an optional value hits the exact + * `properties` AND listed in `required` - an optional value hits the exact * same "caller structurally cannot supply it on every call that omits it" * problem as an undeclared one, just less often. This is the config-load * gate `agent-commerce validate`/`doctor` catch it at; the runtime @@ -1214,12 +1214,12 @@ function validatePathParametersDeclared( // runtime containment check is skipped because its literal prefix // (`http://`) does not itself parse as a URL; and `encodeURIComponent` does // not escape dots, so a hostname survives it whole. Caller input then chooses - // which host the gateway calls — the metadata service, an internal address, + // which host the gateway calls - the metadata service, an internal address, // anything. `http://{region}.api.internal/...` is an ordinary-looking // multi-tenant template, so this is refused here rather than warned about. // // The rule: everything before the first `{` must already be a complete - // authority — a parseable origin followed by the path's leading `/`. + // authority - a parseable origin followed by the path's leading `/`. const firstBrace = url.indexOf('{'); if (firstBrace !== -1) { const prefix = url.slice(0, firstBrace); @@ -1228,7 +1228,7 @@ function validatePathParametersDeclared( const parsedPrefix = new URL(prefix); origin = parsedPrefix.hostname === '' ? undefined : parsedPrefix.origin; } catch { - // Not a URL at all — the parameter starts before the authority is done. + // Not a URL at all - the parameter starts before the authority is done. } if (origin === undefined || !prefix.startsWith(`${origin}/`)) { throw new CommerceError( @@ -1253,14 +1253,14 @@ function validatePathParametersDeclared( if (!Object.hasOwn(properties, param)) { throw new CommerceError( 'CONFIG_INVALID', - `Resource "${id}" has backend.url path parameter "{${param}}" which is not declared in ${where} — the caller has no way to supply it, so every call would settle payment (if priced) and then fail to reach the backend`, + `Resource "${id}" has backend.url path parameter "{${param}}" which is not declared in ${where} - the caller has no way to supply it, so every call would settle payment (if priced) and then fail to reach the backend`, { details: { path, resourceId: id, param } }, ); } if (!required.has(param)) { throw new CommerceError( 'CONFIG_INVALID', - `Resource "${id}" has backend.url path parameter "{${param}}" declared in ${where} but not listed in its "required" — a caller that omits it hits the same unservable-request problem as an undeclared parameter`, + `Resource "${id}" has backend.url path parameter "{${param}}" declared in ${where} but not listed in its "required" - a caller that omits it hits the same unservable-request problem as an undeclared parameter`, { details: { path, resourceId: id, param } }, ); } diff --git a/src/core/domain/resource.ts b/src/core/domain/resource.ts index ab5008d..9cf744b 100644 --- a/src/core/domain/resource.ts +++ b/src/core/domain/resource.ts @@ -30,7 +30,7 @@ export interface BackendHandler { * from top-level input and everything left over becomes either the query * string (GET/DELETE) or the entire JSON body (POST/PUT/PATCH). That mapping * cannot express `POST /users/{userId}/orders?notify=true` with a JSON body - * — one perfectly ordinary REST operation with all three parts at once. + * - one perfectly ordinary REST operation with all three parts at once. * * When present, each group is sourced independently and top-level input that * no binding names is not forwarded to the backend at all. diff --git a/src/core/execution/backend-http.ts b/src/core/execution/backend-http.ts index 0359935..5dab706 100644 --- a/src/core/execution/backend-http.ts +++ b/src/core/execution/backend-http.ts @@ -2,12 +2,12 @@ * The only outbound HTTP path to a merchant backend. * * - Uses global `fetch` and always applies a bound timeout via `AbortSignal.timeout`. - * - `redirect: 'manual'` — a 3xx response is treated as a `BACKEND_ERROR`, never + * - `redirect: 'manual'` - a 3xx response is treated as a `BACKEND_ERROR`, never * followed (SSRF hardening). * - `{param}` segments in `handler.url` are filled from validated input and * URL-encoded; whatever remains goes to the query string (GET/DELETE) or a * JSON body (POST/PUT/PATCH). `handler.inputBindings` replaces that - * leftover rule with one that names each group explicitly — see + * leftover rule with one that names each group explicitly - see * `buildBackendRequestParts`. */ import { @@ -28,12 +28,12 @@ const MAX_BODY_SNIPPET_LENGTH = 512; * REST, and under the previous `[a-zA-Z0-9_]+` class it matched *nothing*. It * was therefore invisible to the config gate, survived substitution as a * literal, and a paid resource settled the buyer's payment before sending - * `/report/%7Breport-id%7D` to a backend that 404s — the earlier money bug, + * `/report/%7Breport-id%7D` to a backend that 404s - the earlier money bug, * reachable again through the character class rather than through the check. */ const PATH_PARAM_PATTERN = /\{([a-zA-Z0-9_.-]+)\}/g; /** A hostile or broken backend can otherwise materialise an arbitrarily - * large response in memory — AbortSignal.timeout bounds it by *time*, not + * large response in memory - AbortSignal.timeout bounds it by *time*, not * bytes. 1 MB is generous for a JSON API response. */ const MAX_RESPONSE_BODY_BYTES = 1024 * 1024; @@ -103,7 +103,7 @@ export class HttpBackendExecutor implements BackendExecutor { //.set() REPLACES an existing param, so without this check a caller // input key with the same name as an operator-baked-in query param // (?apikey=SECRET in handler.url) silently overwrites it. - // Shared with validateBackendRequestShape() — that copy runs + // Shared with validateBackendRequestShape() - that copy runs // *before* payment, this one is defence in depth. checkQueryCollision(target, parts.query, context); for (const [key, value] of Object.entries(parts.query)) { @@ -191,7 +191,7 @@ export class HttpBackendExecutor implements BackendExecutor { if (response.status < 200 || response.status >= 300) { // The backend status is ours to state; the backend's own response body - // is not — a merchant backend in verbose/dev-error mode routinely + // is not - a merchant backend in verbose/dev-error mode routinely // emits stack traces, hostnames or SQL fragments, and this gateway is // not the one who gets to decide those are safe to forward to whoever // called the (possibly free, possibly unauthenticated) resource. Log @@ -223,17 +223,17 @@ export class HttpBackendExecutor implements BackendExecutor { /** * The traversal and query-collision checks below must run before payment, - * not only inside `call()` — pipeline step 6, which is *after* + * not only inside `call()` - pipeline step 6, which is *after* * verify -> reserve -> settle. * Both throw INPUT_INVALID, which schema validation (step 2) cannot catch * (it validates against `resource.inputSchema`, which knows nothing about * the URL template a bad value would collide with). Concretely: a paid, * path-templated resource called with `{ city: "" }` would settle the buyer's - * payment on-chain and then never call the backend — payment without + * payment on-chain and then never call the backend - payment without * delivery, no refund, no release of the reserved authorisation. Both * checks are pure functions of `(handler.url, input)` with no I/O, so the * pipeline calls this immediately after schema validation, *before* price - * resolution — before any payment provider is even selected. `call()` keeps + * resolution - before any payment provider is even selected. `call()` keeps * its own copy as defence in depth (it is a public class; the pipeline is * not the only possible caller). */ @@ -241,7 +241,7 @@ export class HttpBackendExecutor implements BackendExecutor { * `{param}` names in a `backend.url` template, in declaration order. * Exported so `src/config`'s `normaliseResource` can * cross-check every template parameter against the resource's input schema - * at config load — the same regex, not a second copy that could silently + * at config load - the same regex, not a second copy that could silently * drift out of sync with what this file actually treats as a path * parameter. */ @@ -257,7 +257,7 @@ export function extractPathParameterNames(url: string): string[] { * "matches nothing" is precisely the silent-literal shape that costs a buyer * money on a paid resource. So the rule is inverted: rather than enumerating * what is illegal, anything brace-shaped that is *not* a recognised parameter - * is refused at config load. Exported so `src/config` applies it — the + * is refused at config load. Exported so `src/config` applies it - the * grammar and its residue must never live in two files. */ export function findUnparsedBraceToken(url: string): string | undefined { @@ -276,8 +276,8 @@ export function validateBackendRequestShape( ): void { const inputRecord = isPlainObject(input) ? input : {}; - // Every shape error — missing or invalid path parameter, a bound group that - // is not an object — throws INPUT_INVALID from here, before payment. + // Every shape error - missing or invalid path parameter, a bound group that + // is not an object - throws INPUT_INVALID from here, before payment. const parts = buildBackendRequestParts(handler, inputRecord, context); let target: URL; @@ -305,7 +305,7 @@ function acceptsBody(method: BackendMethod): boolean { /** * Split validated input into URL, query and body according to - * `handler.inputBindings` — the single place either mode is decided, so + * `handler.inputBindings` - the single place either mode is decided, so * `call()` and the pre-payment `validateBackendRequestShape()` can never * disagree about what request the input describes. * @@ -334,7 +334,7 @@ function buildBackendRequestParts( // An absent body value sends no body at all rather than `null`: a request // body the operation does not require is simply not there. A body the // operation *does* require is caught one step earlier, by `required` in the - // resource's input schema — also before payment. + // resource's input schema - also before payment. const bodyValue = bindings.body === undefined ? undefined : input[bindings.body]; if (bodyValue === undefined || !acceptsBody(handler.method)) return { url, query }; return { url, query, body: { value: bodyValue } }; @@ -379,7 +379,7 @@ function isPlainObject(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } -// encodeURIComponent does not escape "." — a raw ".."/"." path-parameter +// encodeURIComponent does not escape "." - a raw ".."/"." path-parameter // value survives it and new URL() then normalises the segment away, letting // a caller step outside the template's own directory (bounded traversal: // slashes ARE escaped, so only removing segments, never adding them). Reject @@ -415,7 +415,7 @@ function applyPathTemplate( if (missing !== undefined) { // Tempting to shrug this off as a config/schema mismatch rather than bad // caller input. But a paid, `{param}`-templated resource whose input can - // never supply it would reach settle() on every call — the buyer pays, the + // never supply it would reach settle() on every call - the buyer pays, the // backend is never called, no refund. `normaliseResource` (src/config) is // the root-cause fix, rejecting the shape at config load; throwing here // stops a hand-built `CommerceResource` from reintroducing the money bug diff --git a/src/openapi/discover.ts b/src/openapi/discover.ts index 7366d1e..6804527 100644 --- a/src/openapi/discover.ts +++ b/src/openapi/discover.ts @@ -99,7 +99,7 @@ export function discoverOperations( if (previous !== undefined) { throw new CommerceError( 'CONFIG_INVALID', - `Operations "${previous}" and "${where}" both produce the resource id "${resourceId}". Give one of them a distinct operationId — ids are what agents discover, so the importer will not rename either`, + `Operations "${previous}" and "${where}" both produce the resource id "${resourceId}". Give one of them a distinct operationId - ids are what agents discover, so the importer will not rename either`, { details: { resourceId, operations: [previous, where] } }, ); } @@ -137,6 +137,7 @@ export function discoverOperations( path, backendUrl, ...(operationId !== undefined ? { operationId } : {}), + tags: toArray(operation['tags']).filter((tag): tag is string => typeof tag === 'string'), name: summary ?? operationId ?? resourceId, ...(description !== undefined ? { description } : {}), parameters: [ @@ -186,7 +187,7 @@ function normaliseId(value: string): string { * * A relative server URL (`/v1`, the OpenAPI default of `/`) names no host, and * guessing one from the filename or from localhost would silently point a - * merchant's gateway at the wrong backend — so it is refused and `--base-url` + * merchant's gateway at the wrong backend - so it is refused and `--base-url` * asked for instead. */ function selectServer( diff --git a/src/openapi/draft.ts b/src/openapi/draft.ts new file mode 100644 index 0000000..1cef3a9 --- /dev/null +++ b/src/openapi/draft.ts @@ -0,0 +1,288 @@ +/** + * Operation candidates -> Agent Commerce resource drafts. + * + * A draft describes the *API shape* and nothing else. Pricing, exposure and + * payment methods are commerce policy: an OpenAPI document has no opinion on + * whether an operation should cost money or be visible to agents, so guessing + * one would put a merchant's endpoint on an agent network - or give it away + * free - on the strength of a file that never mentioned either. Without + * explicit CLI policy the generated file is deliberately incomplete: it will + * not load until a human fills those fields in. + */ +import { Document, type Node, type Pair, type YAMLMap } from 'yaml'; +import type { JsonSchema } from '../core/domain/common.js'; +import { discoverOperations } from './discover.js'; +import { mapRequest, pickJsonMediaType } from './request.js'; +import { convertSchema } from './schema.js'; +import type { + ImportDiagnostic, + LoadedOpenApiDocument, + OpenApiOperationCandidate, + OpenApiVersion, +} from './types.js'; + +/** Commerce policy the operator supplied explicitly. Never inferred. */ +export interface ImportPolicy { + readonly pricing?: Record; + readonly expose?: readonly string[]; + readonly payments?: readonly string[]; +} + +export interface ImportOptions { + readonly baseUrl?: string; + readonly policy?: ImportPolicy; + /** `--operation` / `--tag`. Applied before mapping, so unselected operations produce no noise. */ + readonly include?: { + readonly operationIds?: readonly string[]; + /** Multiple tags are OR-ed. */ + readonly tags?: readonly string[]; + }; +} + +export interface ResourceDraft { + readonly id: string; + readonly operationId?: string; + readonly tags: readonly string[]; + /** `METHOD /path`, for the console summary. */ + readonly source: string; + /** YAML-ready, in the field order it will be written in. */ + readonly resource: Record; + /** Comment lines written above this resource. */ + readonly review: readonly string[]; +} + +export interface ImportResult { + readonly version: OpenApiVersion; + readonly sourcePath: string; + readonly drafts: readonly ResourceDraft[]; + readonly diagnostics: readonly ImportDiagnostic[]; + /** `--operation` values that matched nothing. The CLI exits non-zero on these. */ + readonly unmatchedOperationIds: readonly string[]; +} + +export function buildResourceDrafts( + loaded: LoadedOpenApiDocument, + options: ImportOptions = {}, +): ImportResult { + const discovery = discoverOperations( + loaded, + options.baseUrl !== undefined ? { baseUrl: options.baseUrl } : {}, + ); + const diagnostics: ImportDiagnostic[] = [...discovery.diagnostics]; + const drafts: ResourceDraft[] = []; + + const wanted = options.include?.operationIds; + const wantedTags = options.include?.tags; + const matched = new Set(); + + for (const candidate of discovery.operations) { + if (wanted !== undefined && !selects(wanted, candidate)) continue; + if (wantedTags !== undefined && !candidate.tags.some((tag) => wantedTags.includes(tag))) { + continue; + } + if (wanted !== undefined) { + for (const id of wanted) if (selects([id], candidate)) matched.add(id); + } + + const mapping = mapRequest(loaded, candidate); + diagnostics.push(...mapping.diagnostics); + if (!mapping.supported) continue; + + const dropped = new Set(mapping.droppedKeywords); + const output = selectOutputSchema(loaded.document, candidate, diagnostics, dropped); + if (dropped.size > 0) { + diagnostics.push({ + severity: 'warning', + code: 'unenforced-schema-constraints', + operation: candidate.resourceId, + message: `${candidate.resourceId}: dropped ${[...dropped].join(', ')} - this gateway validates structure only, and keeping them would advertise checks it never performs`, + }); + } + + const review: string[] = []; + if (declaresSecurity(candidate)) { + diagnostics.push({ + severity: 'warning', + code: 'backend-authentication-required', + operation: candidate.resourceId, + message: `${candidate.resourceId} declares backend authentication. OpenAPI credentials were not imported. Configure backend.headers with environment placeholders before enabling the resource`, + }); + review.push( + 'This operation declares backend authentication. No credential was imported.', + 'Add it under backend.headers with an ${ENV_VAR} placeholder before enabling.', + ); + } + if (options.policy?.pricing === undefined || options.policy.expose === undefined) { + review.push( + 'REVIEW: pricing and exposure are not inferred from OpenAPI. Add e.g.', + ' pricing: { type: free } # or { type: fixed, amount: "0.01", currency: USDC }', + ' expose: [http] # http | mcp | a2a', + ); + } + + drafts.push({ + id: candidate.resourceId, + ...(candidate.operationId !== undefined ? { operationId: candidate.operationId } : {}), + tags: candidate.tags, + source: `${candidate.method} ${candidate.path}`, + resource: { + name: candidate.name, + ...(candidate.description !== undefined ? { description: candidate.description } : {}), + input: mapping.inputSchema, + ...(output !== undefined ? { output } : {}), + backend: { + type: 'http', + method: candidate.method, + url: candidate.backendUrl, + ...(mapping.contentType !== undefined + ? { headers: { 'Content-Type': mapping.contentType } } + : {}), + ...(Object.keys(mapping.inputBindings).length > 0 + ? { inputBindings: mapping.inputBindings } + : {}), + }, + // Policy is written only when the operator asked for it. An absent + // `pricing`/`expose` is what makes the draft fail config validation + // until a human has decided. + ...(options.policy?.pricing !== undefined ? { pricing: options.policy.pricing } : {}), + ...(options.policy?.expose !== undefined ? { expose: [...options.policy.expose] } : {}), + ...(options.policy?.payments !== undefined + ? { payments: [...options.policy.payments] } + : {}), + }, + review, + }); + } + + return { + version: loaded.version, + sourcePath: loaded.sourcePath, + drafts, + diagnostics, + unmatchedOperationIds: (wanted ?? []).filter((id) => !matched.has(id)), + }; +} + +/** `--operation` accepts the OpenAPI operationId or the generated resource id. */ +function selects(ids: readonly string[], candidate: OpenApiOperationCandidate): boolean { + return ( + ids.includes(candidate.resourceId) || + (candidate.operationId !== undefined && ids.includes(candidate.operationId)) + ); +} + +/** `security: []` means "explicitly none"; `[{}]` means optional. Neither needs a credential. */ +function declaresSecurity(candidate: OpenApiOperationCandidate): boolean { + return candidate.security.some( + (requirement) => + typeof requirement === 'object' && + requirement !== null && + Object.keys(requirement).length > 0, + ); +} + +/** + * One success response, chosen the same way every run: 200, then 201, then + * 202, then the remaining explicit 2xx in ascending order. Status-dependent + * unions are out of scope, so when several 2xx carry materially different + * schemas the operator is told which one was taken rather than left to + * discover it from the diff. + */ +function selectOutputSchema( + document: Record, + candidate: OpenApiOperationCandidate, + diagnostics: ImportDiagnostic[], + dropped: Set, +): JsonSchema | undefined { + const responses = candidate.responses; + if (!isRecord(responses)) return undefined; + + const successes = Object.keys(responses) + .filter((status) => /^2\d\d$/.test(status)) + .sort((a, b) => rank(a) - rank(b)); + if (successes.length === 0) return undefined; + + const withBody = successes.filter((status) => { + const response = responses[status]; + const content = isRecord(response) ? response['content'] : undefined; + return isRecord(content) && pickJsonMediaType(Object.keys(content)) !== undefined; + }); + const chosen = withBody[0]; + if (chosen === undefined) return undefined; // 204, or no JSON representation + + const response = responses[chosen] as Record; + const content = response['content'] as Record; + const mediaType = pickJsonMediaType(Object.keys(content)) as string; + const media = content[mediaType]; + const schemaNode = isRecord(media) ? media['schema'] : undefined; + if (schemaNode === undefined) return undefined; + + const converted = convertSchema(document, schemaNode); + if (!converted.supported) { + // Output schema is descriptive: omitting it costs discovery detail, not + // request safety, so it never skips the operation. + diagnostics.push({ + severity: 'warning', + code: 'unsupported-output-schema', + operation: candidate.resourceId, + message: `${candidate.resourceId}: omitted the output schema (${converted.reason})`, + }); + return undefined; + } + for (const keyword of converted.dropped) dropped.add(keyword); + + if (withBody.length > 1) { + diagnostics.push({ + severity: 'warning', + code: 'multiple-success-responses', + operation: candidate.resourceId, + message: `${candidate.resourceId}: responses ${withBody.join(', ')} all carry a JSON body; used ${chosen}`, + }); + } + return converted.schema; +} + +function rank(status: string): number { + const preferred = ['200', '201', '202'].indexOf(status); + return preferred === -1 ? 100 + Number(status) : preferred; +} + +/** + * Renders the drafts as a config fragment: the same `resources:` shape + * `config.yaml` uses, so a reviewed block can be moved across whole. + */ +export function renderResourcesYaml(result: ImportResult): string { + const resources: Record = {}; + for (const draft of result.drafts) resources[draft.id] = draft.resource; + + const doc = new Document({ resources }); + doc.commentBefore = [ + ' Generated by agent-commerce import openapi. Review before use.', + ` Source: ${basename(result.sourcePath)} (OpenAPI ${result.version})`, + ' Merge the resources below into config.yaml once pricing, exposure and', + ' any backend authentication have been decided.', + ].join('\n'); + + // Comments hang off the key node; a Pair has nowhere to put one. Map items + // are in insertion order, which is draft order. + const map = doc.getIn(['resources'], true) as YAMLMap | undefined; + map?.items.forEach((item, index) => { + const draft = result.drafts[index]; + const key = (item as Pair).key; + if (draft === undefined || draft.review.length === 0 || key === null) return; + key.commentBefore = draft.review.map((line) => ` ${line}`).join('\n'); + }); + + // lineWidth 0 disables folding: a wrapped description would otherwise + // re-flow whenever an unrelated word changed, and the generated file is + // meant to be reviewed in a diff. + return doc.toString({ lineWidth: 0 }); +} + +function basename(path: string): string { + return path.split(/[\\/]/).pop() ?? path; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/src/openapi/index.ts b/src/openapi/index.ts index eee66a1..7f9d6f7 100644 --- a/src/openapi/index.ts +++ b/src/openapi/index.ts @@ -3,7 +3,16 @@ * resource drafts for a human to review, and nothing here is on the runtime * path or in the frozen contract. */ + export { type DiscoverOptions, type DiscoveryResult, discoverOperations } from './discover.js'; +export { + buildResourceDrafts, + type ImportOptions, + type ImportPolicy, + type ImportResult, + type ResourceDraft, + renderResourcesYaml, +} from './draft.js'; export { loadOpenApiDocument, MAX_SOURCE_BYTES } from './load.js'; export { dereference, isRefNode } from './refs.js'; export { mapRequest, type RequestBindings, type RequestMapping } from './request.js'; diff --git a/src/openapi/load.ts b/src/openapi/load.ts index 0bb5be1..acc3720 100644 --- a/src/openapi/load.ts +++ b/src/openapi/load.ts @@ -110,7 +110,7 @@ function readVersion(document: Record, sourcePath: string): Ope const details = { sourcePath }; if (typeof document['swagger'] === 'string') { throw invalid( - `"${sourcePath}" is a Swagger ${document['swagger']} document. Only OpenAPI 3.0, 3.1 and 3.2 are supported — convert it first`, + `"${sourcePath}" is a Swagger ${document['swagger']} document. Only OpenAPI 3.0, 3.1 and 3.2 are supported - convert it first`, details, ); } @@ -131,7 +131,7 @@ function readVersion(document: Record, sourcePath: string): Ope /** * An external `$ref` is refused rather than fetched or read from disk. Both * would be the importer acting on behalf of a document it was merely asked to - * read — one as an outbound request from wherever the CLI runs, the other as a + * read - one as an outbound request from wherever the CLI runs, the other as a * filesystem read outside the source file. Multi-file descriptions are a later * feature; until then, saying so beats a silent partial import. */ diff --git a/src/openapi/refs.ts b/src/openapi/refs.ts index 7fa3337..28b8487 100644 --- a/src/openapi/refs.ts +++ b/src/openapi/refs.ts @@ -3,7 +3,7 @@ * * The document is deliberately *not* dereferenced up front. A recursive schema * (`Node.children[] -> Node`) expands without bound, so a whole-document - * dereference turns a 30 kB file into an out-of-memory kill — an importer that + * dereference turns a 30 kB file into an out-of-memory kill - an importer that * a merchant points at their own API must not be a way to do that. Instead * each pointer is followed on demand, with the chain that led here carried * along so a cycle is a diagnostic rather than a hang. @@ -31,7 +31,7 @@ export function isRefNode(value: unknown): value is { $ref: string } { * Follows a chain of `$ref` nodes to the first value that is not one. * * Throws `CONFIG_INVALID` for a cycle, an unresolvable pointer, or an external - * reference — the last is refused at load time too, so reaching it here means + * reference - the last is refused at load time too, so reaching it here means * a caller built a node the loader never saw. */ export function dereference( diff --git a/src/openapi/request.ts b/src/openapi/request.ts index e1edf6c..07c3a0d 100644 --- a/src/openapi/request.ts +++ b/src/openapi/request.ts @@ -26,7 +26,7 @@ const QUERY_GROUP = 'query'; const BODY_GROUP = 'body'; /** - * OpenAPI: parameters named these "SHALL be ignored" — they are transport + * OpenAPI: parameters named these "SHALL be ignored" - they are transport * concerns, and `Authorization` in particular is operator configuration that * must never become an agent-supplied input. */ @@ -163,7 +163,7 @@ export function mapRequest( if (Object.keys(pathProperties).length > 0) { properties[PATH_GROUP] = closedObject(pathProperties, Object.keys(pathProperties)); // OpenAPI path parameters are always required, and a missing one makes the - // request unbuildable — which on a paid resource is payment with no + // request unbuildable - which on a paid resource is payment with no // delivery, so config rejects the shape at load time too. required.push(PATH_GROUP); bindings.path = PATH_GROUP; @@ -285,7 +285,7 @@ function resolveBody( return { kind: 'unsupported', required, - reason: `no JSON request body content type (found: ${Object.keys(content).join(', ') || 'none'}). Only application/json and application/*+json are supported — multipart and form data are never serialized as JSON`, + reason: `no JSON request body content type (found: ${Object.keys(content).join(', ') || 'none'}). Only application/json and application/*+json are supported - multipart and form data are never serialized as JSON`, }; } const media = content[mediaType]; @@ -313,7 +313,7 @@ function resolveBody( } /** Exact `application/json` wins; otherwise the first `+json` in sorted order. */ -function pickJsonMediaType(keys: readonly string[]): string | undefined { +export function pickJsonMediaType(keys: readonly string[]): string | undefined { const normalised = keys.map((key) => ({ key, type: key.split(';')[0]?.trim().toLowerCase() })); const exact = normalised.find((entry) => entry.type === 'application/json'); if (exact !== undefined) return exact.key; diff --git a/src/openapi/schema.ts b/src/openapi/schema.ts index 8c5ea0a..3db40bc 100644 --- a/src/openapi/schema.ts +++ b/src/openapi/schema.ts @@ -5,7 +5,7 @@ * `type`/`properties`/`required`/`additionalProperties`/`enum`/`items` and * silently ignores everything else, so copying a `pattern` or a `oneOf` into a * generated resource would advertise validation to agents that no code - * performs — and on a paid resource, the request the merchant's backend + * performs - and on a paid resource, the request the merchant's backend * receives is the one the buyer already paid for. Unenforceable constraints * are therefore dropped from the generated schema and reported, never * carried along quietly. @@ -92,7 +92,7 @@ function convertNode( dropped: Set, ): JsonSchema | undefined { // OpenAPI 3.1 allows boolean schemas: `true` accepts anything, `false` - // accepts nothing — and nothing is not a request shape we can generate. + // accepts nothing - and nothing is not a request shape we can generate. if (node === true) return {}; if (node === false) throw new UnsupportedSchema('schema is `false`, which accepts no value'); @@ -109,7 +109,7 @@ function convertNode( for (const keyword of ['oneOf', 'anyOf', 'not', 'discriminator']) { if (Object.hasOwn(schemaNode, keyword)) { throw new UnsupportedSchema( - `schema uses "${keyword}", which this gateway cannot enforce — accepting it would advertise validation that never runs`, + `schema uses "${keyword}", which this gateway cannot enforce - accepting it would advertise validation that never runs`, ); } } @@ -131,11 +131,12 @@ function convertNode( if (child !== undefined) converted[name] = child; } result['properties'] = converted; - if (!Object.hasOwn(schemaNode, 'additionalProperties')) result['additionalProperties'] = false; } // Independent of `properties`: `required` without them is legal, and core's // validator enforces it, so dropping it here would be a silent weakening. + // Emitted here so every generated object schema orders its keys the same + // way - the output is meant to be reviewed in a diff. const required = schemaNode['required']; if (Array.isArray(required)) { const names = required.filter((name): name is string => typeof name === 'string'); @@ -148,6 +149,8 @@ function convertNode( } else if (additional !== undefined) { const child = convertNode(document, additional, resolved.stack, dropped); if (child !== undefined) result['additionalProperties'] = child; + } else if (isRecord(properties)) { + result['additionalProperties'] = false; } const items = schemaNode['items']; @@ -209,7 +212,7 @@ function typeList(raw: unknown): string[] | undefined { /** * A simple `allOf` of object schemas is merged; anything else is refused. * - * Merging is only safe while the branches agree — two branches declaring the + * Merging is only safe while the branches agree - two branches declaring the * same property differently have a meaning ("both must hold") that this * validator cannot express, and picking one would quietly accept requests the * API rejects, or reject ones it accepts. diff --git a/src/openapi/types.ts b/src/openapi/types.ts index 5d96e65..dff3548 100644 --- a/src/openapi/types.ts +++ b/src/openapi/types.ts @@ -41,9 +41,11 @@ export interface OpenApiOperationCandidate { /** Selected server + path, with `{param}` templates preserved literally. */ readonly backendUrl: string; readonly operationId?: string; + /** OpenAPI tags, verbatim - the CLI's `--tag` filter reads them. */ + readonly tags: readonly string[]; readonly name: string; readonly description?: string; - /** Path-item parameters first, then operation parameters — unresolved nodes. */ + /** Path-item parameters first, then operation parameters - unresolved nodes. */ readonly parameters: readonly unknown[]; readonly requestBody?: unknown; readonly responses?: unknown; diff --git a/tests/unit/cli/packaging.test.ts b/tests/unit/cli/packaging.test.ts index 338ab59..74490b4 100644 --- a/tests/unit/cli/packaging.test.ts +++ b/tests/unit/cli/packaging.test.ts @@ -2,7 +2,7 @@ * Packaging surface tests. * * The published npm artifact is the only thing an end user ever sees, and it - * is built by a different path than everything else in the repo — so it can + * is built by a different path than everything else in the repo - so it can * break while every other test stays green. These assert the properties the * packaging spec requires, against the real built `dist/`. * @@ -78,7 +78,7 @@ const bareImportsOf = (file: string): string[] => { // follow relative imports. tsup builds the three library // entries with `splitting: true`, so shared code lives in `dist/chunk-*.js`. // Scanning only the entry file meant a peer import that migrated into a - // shared chunk would pass this test while breaking a bare consumer install — + // shared chunk would pass this test while breaking a bare consumer install - // false confidence about the one invariant calls // non-negotiable. const visit = (path: string): void => { @@ -108,7 +108,7 @@ const run = (...args: string[]): string => execFileSync(process.execPath, [distEntry, ...args], { encoding: 'utf8' }); describe('published package metadata', () => { - it('is publishable — not marked private', () => { + it('is publishable - not marked private', () => { expect(manifest.private).toBeUndefined(); }); @@ -147,8 +147,8 @@ describe('published package metadata', () => { // // It is deliberately no longer repo-wide: `@scalar/openapi-parser` (the // OpenAPI importer) needs zod 4 and gets its own nested copy. Nothing - // crosses that seam — the importer hands the parser plain JSON and gets - // plain JSON back — so the two majors never meet a shared `instanceof`. + // crosses that seam - the importer hands the parser plain JSON and gets + // plain JSON back - so the two majors never meet a shared `instanceof`. const overrides = manifest.overrides as Record | undefined; expect(overrides?.['zod']).toBeUndefined(); for (const pkg of ['@x402/core', '@x402/evm', '@coinbase/x402']) { @@ -181,7 +181,7 @@ describe('published package metadata', () => { ]) { expect(manifest.peerDependencies?.[peer]).toBeDefined(); expect(manifest.peerDependenciesMeta?.[peer]?.optional).toBe(true); - // In `dependencies` too would defeat the point — npm installs those. + // In `dependencies` too would defeat the point - npm installs those. expect(manifest.dependencies?.[peer]).toBeUndefined(); //...but the repo itself still builds and tests against them. expect(manifest.devDependencies?.[peer]).toBe(manifest.peerDependencies?.[peer]); @@ -190,7 +190,7 @@ describe('published package metadata', () => { it('declares no workspace:* runtime dependency', () => { // Internal packages are bundled at build time, so they must not appear as - // runtime dependencies — they are not published and npm could not resolve + // runtime dependencies - they are not published and npm could not resolve // them. const leaked = Object.entries(manifest.dependencies ?? {}).filter(([, v]) => v.includes('workspace:'), @@ -201,7 +201,7 @@ describe('published package metadata', () => { it('publishes under the @devlab.group scope, explicitly public', () => { // A scoped package defaults to `restricted`. Without publishConfig.access // a `npm publish` either fails on a free account or silently publishes a - // private package — the failure mode that looks like success. + // private package - the failure mode that looks like success. expect(manifest.name).toBe('@devlab.group/agent-commerce'); expect(manifest.publishConfig?.access).toBe('public'); }); @@ -274,7 +274,7 @@ describe.skipIf(!built)('built executable', () => { }); it('leaves only npm-resolvable modules as imports', () => { - // `dependencies` only — not the optional peers. The binary must run on a + // `dependencies` only - not the optional peers. The binary must run on a // default `npm i @devlab.group/agent-commerce`, with nothing else installed. const declared = new Set(Object.keys(manifest.dependencies ?? {})); for (const pkg of bareImportsOf(distEntry)) { @@ -305,7 +305,7 @@ describe.skipIf(!existsSync(libEntry))('built library entry', () => { it('does not re-export the optional-peer adapters', () => { // They moved to `@devlab.group/agent-commerce/mcp` and `/x402`. Re-exporting them // here would statically import x402 and the MCP SDK from the main entry, - // which is precisely what makes the peers non-optional again — a bare + // which is precisely what makes the peers non-optional again - a bare // install would fail on `import { createGateway }`. const out = load( "process.stdout.write(['mcp','x402','createMcpAdapter','createX402PaymentProvider'].filter((k) => m[k] !== undefined).join(','))", @@ -325,7 +325,7 @@ describe.skipIf(!existsSync(libEntry))('built library entry', () => { JSON.parse(readFileSync(join(pkgRoot, 'package.json'), 'utf8')) as { version: string } ).version; expect(version).not.toContain('0.0.0-unknown'); - // The likeliest cause of a mismatch here is a stale `dist/` — the version + // The likeliest cause of a mismatch here is a stale `dist/` - the version // is injected by tsup at build time, so a bundle built before a version // change keeps reporting the old one and turns the whole suite red for a // reason that has nothing to do with the source. Say so in the failure, @@ -333,7 +333,7 @@ describe.skipIf(!existsSync(libEntry))('built library entry', () => { expect( version, `built bundle reports "${version}" but package.json says "${manifestVersion}". ` + - 'The version is injected at build time, so `dist/` is almost certainly stale — ' + + 'The version is injected at build time, so `dist/` is almost certainly stale - ' + 'run `npm run build` and re-run this test.', ).toBe(manifestVersion); }); @@ -380,7 +380,7 @@ describe.skipIf(!existsSync(libEntry))('optional-peer subpaths', () => { it('shares one CommerceError class with the main entry', () => { // Built as independent bundles each entry carries its own copy, and - // `catch (e) { e instanceof CommerceError }` is false across the seam — + // `catch (e) { e instanceof CommerceError }` is false across the seam - // verified: flipping tsup's `splitting` off makes this assertion fail. // Same class of defect described for two zod majors. const out = execFileSync( diff --git a/tests/unit/config/schema.test.ts b/tests/unit/config/schema.test.ts index 5183465..e12d060f 100644 --- a/tests/unit/config/schema.test.ts +++ b/tests/unit/config/schema.test.ts @@ -197,7 +197,7 @@ describe('parseConfig', () => { * parses so the URL check passes; the runtime containment check is skipped * because its literal prefix (`http://`) does not itself parse as a URL; and * `encodeURIComponent` does not escape dots, so a hostname survives whole. - * Caller input would then choose which host the gateway calls — the cloud + * Caller input would then choose which host the gateway calls - the cloud * metadata service, an internal address, anything. */ it.each([ @@ -235,7 +235,7 @@ describe('parseConfig', () => { /** * The stamper's third drift from the validator, after `required` and tuple * `items`. `additionalProperties: {schema}` is the idiomatic "map of typed - * objects" shape and the validator applies that subschema recursively — so + * objects" shape and the validator applies that subschema recursively - so * without recursion here, every node beneath it stayed open and unknown * keys reached the merchant's backend. */ @@ -339,7 +339,7 @@ describe('parseConfig', () => { }); it('rejects a mainnet served by the in-process facilitator', () => { - // The local facilitator signs with a key this process holds — a hot + // The local facilitator signs with a key this process holds - a hot // wallet inside the resource server, which is the arrangement this // project exists to avoid. const message = messageFor( @@ -378,7 +378,7 @@ describe('parseConfig', () => { const message = messageFor(withX402(unauthenticated)); expect(message).toContain('allowUnauthenticatedFacilitator'); // The origin, so an operator can see *which* counterparty they are being - // asked about — but never the path, which can carry a tenant or a key. + // asked about - but never the path, which can carry a tenant or a key. expect(message).toContain('https://facilitator.example.com'); expect(message).not.toContain('/v2/x402'); @@ -430,7 +430,7 @@ describe('parseConfig', () => { }); it('allows a plain-HTTP facilitator on a private host below mainnet', () => { - // A dot-free host is a compose/k8s service name — the traffic never + // A dot-free host is a compose/k8s service name - the traffic never // leaves the deployment, so requiring TLS there would only block the // normal self-hosted arrangement. expect(() => @@ -445,7 +445,7 @@ describe('parseConfig', () => { }); it('rejects a well-known development payTo on a non-local deployment', () => { - // The fixture's payTo is Anvil account #1 — fine locally, catastrophic + // The fixture's payTo is Anvil account #1 - fine locally, catastrophic // anywhere the money is real, because its private key is public. const message = messageFor( withX402({ facilitator: { mode: 'remote', url: 'https://facilitator.example.com' } }), @@ -689,7 +689,7 @@ describe('parseConfig', () => { expectConfigInvalid(() => parseConfig(raw, {})); }); - it('rejects a paid, {param}-templated resource whose input: is missing entirely — the caller could never supply it, so every call would settle payment and never reach the backend', () => { + it('rejects a paid, {param}-templated resource whose input: is missing entirely - the caller could never supply it, so every call would settle payment and never reach the backend', () => { const raw = validRawConfig(); const resources = raw['resources'] as Record>; const weather = resources['weather_basic'] as Record; @@ -707,7 +707,7 @@ describe('parseConfig', () => { } }); - it('rejects the same resource when {city} is declared but not required (optional) — a caller that omits it hits the identical bug', () => { + it('rejects the same resource when {city} is declared but not required (optional) - a caller that omits it hits the identical bug', () => { const raw = validRawConfig(); const resources = raw['resources'] as Record>; const weather = resources['weather_basic'] as Record; @@ -715,7 +715,7 @@ describe('parseConfig', () => { type: 'object', properties: { city: { type: 'string' } }, additionalProperties: false, - // no `required` — this is the trap: declared, but still unenforceable. + // no `required` - this is the trap: declared, but still unenforceable. }; weather['pricing'] = { type: 'fixed', amount: '0.01', currency: 'USDC' }; weather['payments'] = ['x402']; @@ -734,7 +734,7 @@ describe('parseConfig', () => { ['a wildcard', '*'], ['a trailing slash', 'http://localhost:5173/'], ])( - 'rejects an allowedOrigins entry with %s — it is matched literally and would never match', + 'rejects an allowedOrigins entry with %s - it is matched literally and would never match', (_label, origin) => { // Fail-closed (a lockout, not a bypass) but silent, and a lockout with // no explanation is what an operator "fixes" by disabling the check. @@ -757,7 +757,7 @@ describe('parseConfig', () => { ['whitespace only', ' '], ['hex notation', '0x50'], ['exponent notation', '1e3'], - ])('rejects server.port given as %s — Number() would coerce it silently', (_label, port) => { + ])('rejects server.port given as %s - Number() would coerce it silently', (_label, port) => { // `Number('')` is 0: finite, integral, and inside port's deliberate // `min: 0` ("let the OS pick"). So `port: ${PORT:-}` validated PASS, the // gateway bound a random port, and `doctor` then derived @@ -767,7 +767,7 @@ describe('parseConfig', () => { expectConfigInvalid(() => parseConfig(raw, {})); }); - it('control: decimal digits, as a string or a number, still work — including 0 meaning "let the OS pick"', () => { + it('control: decimal digits, as a string or a number, still work - including 0 meaning "let the OS pick"', () => { for (const port of ['8080', 8080, '0', 0]) { const raw = validRawConfig(); (raw['server'] as Record)['port'] = port; @@ -775,12 +775,12 @@ describe('parseConfig', () => { } }); - it('stamps additionalProperties:false on a node that declares only "required" — core treats it as an object, so config must too', () => { + it('stamps additionalProperties:false on a node that declares only "required" - core treats it as an object, so config must too', () => { // The two definitions of "this is an object schema" were one keyword // apart: config looked at type/properties, core at properties/required. // `input: { required: ['q'] }` was an object to the validator and not to // the stamper, so it normalised to exactly {"required":["q"]} and every - // unknown caller key was forwarded verbatim to the merchant's backend — + // unknown caller key was forwarded verbatim to the merchant's backend - // while docs/security.md promised closed-by-default at every level. const raw = validRawConfig(); const resources = raw['resources'] as Record>; @@ -798,7 +798,7 @@ describe('parseConfig', () => { expect(validate({ q: 'ok', evil: 'extra-key' }).valid).toBe(false); }); - it('rejects a "required" name that "properties" never declares — closing it makes the schema unsatisfiable by any input', () => { + it('rejects a "required" name that "properties" never declares - closing it makes the schema unsatisfiable by any input', () => { const raw = validRawConfig(); const resources = raw['resources'] as Record>; const weather = resources['weather_basic'] as Record; @@ -815,7 +815,7 @@ describe('parseConfig', () => { } }); - it('control: an explicit additionalProperties:true is left open — the operator opted in', () => { + it('control: an explicit additionalProperties:true is left open - the operator opted in', () => { const raw = validRawConfig(); const resources = raw['resources'] as Record>; const weather = resources['weather_basic'] as Record; @@ -829,7 +829,7 @@ describe('parseConfig', () => { ).toBe(true); }); - it('rejects a paid resource whose {param} uses a kebab name — it matched no grammar, so the gate saw zero parameters and passed', () => { + it('rejects a paid resource whose {param} uses a kebab name - it matched no grammar, so the gate saw zero parameters and passed', () => { // The regression that reopened the earlier money bug through the character // class rather than through the check. `{report-id}` is ordinary REST. // Under `[a-zA-Z0-9_]+` it matched nothing: the gate found no parameters @@ -859,7 +859,7 @@ describe('parseConfig', () => { ['nothing at all', 'http://localhost:3000/api/report/{}'], ['an unbalanced brace', 'http://localhost:3000/api/report/{oops'], ])( - 'rejects a brace token containing %s — widening the grammar cannot cover every spelling, so anything brace-shaped that is not a parameter is refused', + 'rejects a brace token containing %s - widening the grammar cannot cover every spelling, so anything brace-shaped that is not a parameter is refused', (_label, url) => { const raw = validRawConfig(); const resources = raw['resources'] as Record>; @@ -876,7 +876,7 @@ describe('parseConfig', () => { }, ); - it('control: a kebab {param} that IS declared and required loads and stays servable — the fix must not reject ordinary REST', () => { + it('control: a kebab {param} that IS declared and required loads and stays servable - the fix must not reject ordinary REST', () => { const raw = validRawConfig(); const resources = raw['resources'] as Record>; const report = resources['market_report'] as Record; @@ -907,11 +907,11 @@ describe('parseConfig', () => { expect(() => parseConfig(raw, {})).not.toThrow(); }); - it('control: a paid, {param}-templated resource with city correctly required still loads — do not over-reject', () => { + it('control: a paid, {param}-templated resource with city correctly required still loads - do not over-reject', () => { const raw = validRawConfig(); const resources = raw['resources'] as Record>; const weather = resources['weather_basic'] as Record; - // fixtures.ts's weather_basic already declares `required: [city]` — only + // fixtures.ts's weather_basic already declares `required: [city]` - only // switch it to paid, which is the shape the money bug actually needs. weather['pricing'] = { type: 'fixed', amount: '0.01', currency: 'USDC' }; weather['payments'] = ['x402']; @@ -1091,7 +1091,7 @@ describe('parseConfig', () => { }); }); - it('rejects a schema declaring properties/required whose type excludes "object" — the validator would never enforce either', () => { + it('rejects a schema declaring properties/required whose type excludes "object" - the validator would never enforce either', () => { const raw = validRawConfig(); ( raw['resources'] as { market_report: { input: Record } } @@ -1110,7 +1110,7 @@ describe('parseConfig', () => { } }); - it('control: properties/required with NO type at all still loads — the validator treats that as an object schema now', () => { + it('control: properties/required with NO type at all still loads - the validator treats that as an object schema now', () => { const raw = validRawConfig(); ( raw['resources'] as { market_report: { input: Record } } @@ -1171,7 +1171,7 @@ describe('parseConfig', () => { filter: { type: 'object', properties: { anything: { type: 'string' } }, - // no additionalProperties here — this is the bug: the root gets + // no additionalProperties here - this is the bug: the root gets // closed, this level did not. }, }, @@ -1626,7 +1626,7 @@ describe('parseConfig backend.inputBindings', () => { parseConfig( bindingConfig({ bindings, - // userId declared at the top level, not under the path group — the + // userId declared at the top level, not under the path group - the // pre-bindings shape, which explicit mode no longer reads from. input: { type: 'object', diff --git a/tests/unit/openapi/discover.test.ts b/tests/unit/openapi/discover.test.ts index e2b7daf..df99d08 100644 --- a/tests/unit/openapi/discover.test.ts +++ b/tests/unit/openapi/discover.test.ts @@ -27,7 +27,7 @@ describe('discoverOperations', () => { it('normalises an operationId that is not a legal resource id', async () => { const result = await discover('minimal-3.1.json'); - // "list things!" — the space and "!" are not in the id character set. + // "list things!" - the space and "!" are not in the id character set. expect(ids(result)).toEqual(['list_things']); }); diff --git a/tests/unit/openapi/draft.test.ts b/tests/unit/openapi/draft.test.ts new file mode 100644 index 0000000..647a259 --- /dev/null +++ b/tests/unit/openapi/draft.test.ts @@ -0,0 +1,158 @@ +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { parseConfig } from '../../../src/config/schema.js'; +import { + buildResourceDrafts, + type ImportResult, + type LoadedOpenApiDocument, + loadOpenApiDocument, + renderResourcesYaml, +} from '../../../src/openapi/index.js'; +import { validRawConfig } from '../config/fixtures.js'; + +const fixture = (name: string): string => + join(fileURLToPath(new URL('./fixtures/', import.meta.url)), name); + +let loaded: LoadedOpenApiDocument; +let result: ImportResult; + +beforeAll(async () => { + loaded = await loadOpenApiDocument(fixture('responses-3.1.yaml')); + result = buildResourceDrafts(loaded); +}); + +const draft = (id: string) => result.drafts.find((entry) => entry.id === id); +const diagnostics = (id: string) => result.diagnostics.filter((d) => d.operation === id); +const resource = (id: string) => draft(id)?.resource as Record; + +describe('buildResourceDrafts', () => { + it('takes the 200 JSON response as the output schema', () => { + expect(resource('getOrder')['output']).toEqual({ + type: 'object', + properties: { id: { type: 'string' }, total: { type: 'number' } }, + required: ['id'], + additionalProperties: false, + }); + }); + + it('warns when several 2xx responses carry a body, naming the one it used', () => { + const warning = diagnostics('getOrder').find((d) => d.code === 'multiple-success-responses'); + expect(warning?.message).toContain('used 200'); + }); + + it('falls back to 201 and accepts a vendor +json response', () => { + expect(resource('createOrder')['output']).toEqual({ + type: 'object', + properties: { id: { type: 'string' } }, + additionalProperties: false, + }); + }); + + it('omits output for a 204 response', () => { + expect(resource('cancelOrder')).not.toHaveProperty('output'); + }); + + it('omits output when the success response is not JSON', () => { + expect(resource('ping')).not.toHaveProperty('output'); + }); + + it('omits an output schema it cannot represent, and keeps the operation', () => { + expect(draft('union')).toBeDefined(); + expect(resource('union')).not.toHaveProperty('output'); + expect(diagnostics('union').map((d) => d.code)).toContain('unsupported-output-schema'); + }); + + it('reports dropped constraints from input and output together', () => { + const warning = diagnostics('getOrder').find((d) => d.code === 'unenforced-schema-constraints'); + expect(warning?.message).toContain('minimum'); + }); + + it('warns about backend authentication without importing any credential', () => { + const warning = diagnostics('getOrder').find( + (d) => d.code === 'backend-authentication-required', + ); + expect(warning?.message).toContain('credentials were not imported'); + // The API key scheme names an X-Api-Key header; it must not become input. + expect(JSON.stringify(resource('getOrder'))).not.toContain('X-Api-Key'); + expect(JSON.stringify(resource('getOrder'))).not.toContain('apiKey'); + }); + + it('does not warn for an operation that opts out of security', () => { + expect(diagnostics('createOrder').map((d) => d.code)).not.toContain( + 'backend-authentication-required', + ); + // `security: [{}]` means optional, not required. + expect(diagnostics('ping').map((d) => d.code)).not.toContain('backend-authentication-required'); + }); + + it('never infers pricing, exposure or payments', () => { + for (const entry of result.drafts) { + expect(entry.resource).not.toHaveProperty('pricing'); + expect(entry.resource).not.toHaveProperty('expose'); + expect(entry.resource).not.toHaveProperty('payments'); + expect(entry.review.join(' ')).toContain('REVIEW'); + } + }); + + it('writes policy only when the operator supplied it', () => { + const withPolicy = buildResourceDrafts(loaded, { + policy: { pricing: { type: 'free' }, expose: ['http', 'mcp'] }, + }); + const order = withPolicy.drafts.find((entry) => entry.id === 'getOrder'); + expect(order?.resource['pricing']).toEqual({ type: 'free' }); + expect(order?.resource['expose']).toEqual(['http', 'mcp']); + expect(order?.review.join(' ')).not.toContain('REVIEW'); + }); + + it('ignores vendor extensions instead of letting them alter the resource', () => { + expect(JSON.stringify(resource('getOrder'))).not.toContain('x-internal-cost'); + }); + + it('filters by operation id and reports one that matched nothing', () => { + const filtered = buildResourceDrafts(loaded, { + include: { operationIds: ['getOrder', 'nope'] }, + }); + expect(filtered.drafts.map((entry) => entry.id)).toEqual(['getOrder']); + expect(filtered.unmatchedOperationIds).toEqual(['nope']); + }); + + it('filters by tag, OR-ing several', () => { + const filtered = buildResourceDrafts(loaded, { include: { tags: ['read', 'write'] } }); + expect(filtered.drafts.map((entry) => entry.id)).toEqual(['getOrder', 'cancelOrder']); + }); +}); + +describe('renderResourcesYaml', () => { + it('is byte-identical across runs of the same document', async () => { + const again = buildResourceDrafts(await loadOpenApiDocument(fixture('responses-3.1.yaml'))); + expect(renderResourcesYaml(again)).toBe(renderResourcesYaml(result)); + }); + + it('writes a reviewable fragment with the review comments above each resource', () => { + const yaml = renderResourcesYaml(result); + expect(yaml).toContain('# Generated by agent-commerce import openapi'); + expect(yaml).toContain('resources:'); + const commentIndex = yaml.indexOf('REVIEW: pricing and exposure are not inferred'); + expect(commentIndex).toBeGreaterThan(-1); + expect(commentIndex).toBeLessThan(yaml.indexOf('getOrder:')); + expect(yaml).toContain('# pricing: { type: free }'); + expect(yaml).toContain('backend authentication'); + }); + + it('produces a fragment that loads once pricing and exposure are chosen', () => { + const withPolicy = buildResourceDrafts(loaded, { + policy: { pricing: { type: 'free' }, expose: ['http'] }, + }); + const raw = validRawConfig(); + const resources: Record = {}; + for (const entry of withPolicy.drafts) resources[entry.id] = entry.resource; + raw['resources'] = resources; + + const config = parseConfig(raw, {}); + expect(config.resources.map((r) => r.id)).toEqual(withPolicy.drafts.map((d) => d.id)); + const order = config.resources.find((r) => r.id === 'getOrder'); + expect(order?.handler.url).toBe('https://api.example.com/orders/{orderId}'); + expect(order?.handler.inputBindings).toEqual({ path: 'path' }); + }); +}); diff --git a/tests/unit/openapi/fixtures/responses-3.1.yaml b/tests/unit/openapi/fixtures/responses-3.1.yaml new file mode 100644 index 0000000..4d2074f --- /dev/null +++ b/tests/unit/openapi/fixtures/responses-3.1.yaml @@ -0,0 +1,116 @@ +openapi: 3.1.0 +info: + title: Responses + version: 1.0.0 +servers: + - url: https://api.example.com +security: + - apiKey: [] +paths: + /orders/{orderId}: + get: + operationId: getOrder + summary: Get order + description: One order by id. + x-internal-cost: 12 + tags: [orders, read] + parameters: + - name: orderId + in: path + required: true + schema: + type: string + responses: + '201': + description: created + content: + application/json: + schema: + type: object + properties: + late: + type: string + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Order' + /orders: + post: + operationId: createOrder + tags: [orders] + security: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + sku: + type: string + required: [sku] + responses: + '201': + description: created + content: + application/vnd.acme+json: + schema: + type: object + properties: + id: + type: string + /orders/{orderId}/cancel: + delete: + operationId: cancelOrder + tags: [orders, write] + parameters: + - name: orderId + in: path + required: true + schema: + type: string + responses: + '204': + description: no content + /pings: + get: + operationId: ping + security: + - {} + responses: + '200': + description: ok + content: + text/plain: + schema: + type: string + /unions: + get: + operationId: union + responses: + '200': + description: ok + content: + application/json: + schema: + oneOf: + - type: string + - type: integer +components: + schemas: + Order: + type: object + properties: + id: + type: string + total: + type: number + minimum: 0 + required: [id] + securitySchemes: + apiKey: + type: apiKey + name: X-Api-Key + in: header diff --git a/tests/unit/openapi/load.test.ts b/tests/unit/openapi/load.test.ts index 2fcea5a..aa64d3a 100644 --- a/tests/unit/openapi/load.test.ts +++ b/tests/unit/openapi/load.test.ts @@ -30,7 +30,7 @@ describe('loadOpenApiDocument', () => { const loaded = await loadOpenApiDocument(fixture('petstore-3.0.yaml')); expect(loaded.version).toBe('3.0'); expect(loaded.sourcePath).toContain('petstore-3.0.yaml'); - // The document is kept verbatim — references are NOT expanded up front. + // The document is kept verbatim - references are NOT expanded up front. const paths = loaded.document['paths'] as Record< string, Record } }> @@ -133,7 +133,7 @@ describe('loadOpenApiDocument', () => { expect(message).toContain('./common.yaml#/Thing'); }); - it('accepts a document whose internal references are cyclic — resolution is lazy', async () => { + it('accepts a document whose internal references are cyclic - resolution is lazy', async () => { const loaded = await loadOpenApiDocument(fixture('cyclic-ref.yaml')); expect(loaded.version).toBe('3.1'); }); diff --git a/tests/unit/openapi/request.test.ts b/tests/unit/openapi/request.test.ts index 67e93e7..8c988c5 100644 --- a/tests/unit/openapi/request.test.ts +++ b/tests/unit/openapi/request.test.ts @@ -164,13 +164,14 @@ describe('mapRequest', () => { it('skips an operation whose {param} is never declared as a path parameter', () => { // The OpenAPI validator rejects this document shape, so the candidate is // built directly: the check exists because a `{param}` nothing can supply - // makes every call unservable — and a paid one settles first. + // makes every call unservable - and a paid one settles first. const result = mapRequest(loaded, { resourceId: 'legacy', method: 'GET', path: '/legacy/{id}', backendUrl: 'https://api.example.com/legacy/{id}', name: 'legacy', + tags: [], parameters: [], security: [], }); @@ -197,6 +198,7 @@ describe('mapRequest', () => { path: '/x', backendUrl: 'https://api.example.com/x', name: 'x', + tags: [], parameters: [], requestBody: { required: true, From c9877920f26cc1fd8fb4a7fff6fa2f52b429a0c2 Mon Sep 17 00:00:00 2001 From: Revinand Date: Fri, 4 Sep 2026 11:07:36 +0200 Subject: [PATCH 6/8] feat(cli): add openapi import command --- docs/contract-surface.txt | 4 +- docs/contracts.md | 1 + package.json | 2 +- src/cli/commands/import-openapi.ts | 234 ++++++++++++++++++++++++++ src/cli/program.ts | 66 +++++++- src/config/schema.ts | 3 +- src/core/domain/common.ts | 17 +- src/core/public-types.ts | 1 + tests/unit/cli/import-openapi.test.ts | 217 ++++++++++++++++++++++++ tests/unit/cli/packaging.test.ts | 12 +- 10 files changed, 551 insertions(+), 6 deletions(-) create mode 100644 src/cli/commands/import-openapi.ts create mode 100644 tests/unit/cli/import-openapi.test.ts diff --git a/docs/contract-surface.txt b/docs/contract-surface.txt index 5edeccd..cfd6f6d 100644 --- a/docs/contract-surface.txt +++ b/docs/contract-surface.txt @@ -1,6 +1,6 @@ # Semantic surface of src/core/public-types.ts # Generated by scripts/contract-surface.mjs — do not edit by hand. -# 70 exported symbols. +# 71 exported symbols. interface AdapterDescriptor { readonly capabilities: ReadonlyArray; @@ -399,6 +399,8 @@ value PAYMENT_REQUIRED_HEADER: "payment-required" value PAYMENT_RESPONSE_HEADER: "payment-response" +value PROTOCOL_NAMES: ReadonlyArray + value RETRYABLE_ERROR_CODES: ReadonlySet<"CONFIG_INVALID" | "RESOURCE_NOT_FOUND" | "INPUT_INVALID" | "PAYMENT_REQUIRED" | "PAYMENT_INVALID" | "PAYMENT_REPLAYED" | "PAYMENT_PROVIDER_UNAVAILABLE" | "PAYMENT_SETTLEMENT_FAILED" | "BACKEND_TIMEOUT" | "BACKEND_ERROR" | "PROTOCOL_UNSUPPORTED" | "GATEWAY_BUSY" | "STORAGE_ERROR" | "INTERNAL_ERROR"> value isCommerceError: (value: unknown) => value is CommerceError diff --git a/docs/contracts.md b/docs/contracts.md index ec6b15d..d200473 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -76,6 +76,7 @@ the generated file is right and this table is stale. - **Additive:** `AdapterHttpRoute` and the optional `HttpProtocolAdapter.additionalHttpRoutes`. A protocol whose specification pins a discovery URL outside the adapter's mount (A2A's `/.well-known/agent-card.json`) declares it instead of the gateway growing a per-protocol route conditional. Fixed routes get the mount's guarantees - unconsumed body, concurrency cap, failure isolation - and two adapters claiming one path is rejected before either starts. - **Removed from the wire:** `/.well-known/agent-commerce` no longer publishes `payments.x402.facilitator.url`. A facilitator endpoint can carry a tenant path or an API key, exactly like `rpcUrl`, which the same route already withholds. It gained `payments.x402.mode` (`local` | `testnet` | `mainnet`) instead - chain id 84532 belongs to both the local dev chain and public Base Sepolia, so the network id alone cannot say which one a client is talking to. - **Additive:** optional `BackendHandler.inputBindings` (`{ path?, query?, body? }`), naming the top-level input properties that carry each part of the backend request. *Use case:* `POST /users/{userId}/orders?notify=true` with a JSON body - path, query and body at once - which the leftover rule cannot express, because on a body-capable method everything not consumed by the URL template becomes the body. *Alternative considered:* infer the split from the input schema's property names; rejected, since the shape a merchant's backend expects is operator configuration, not something to guess from a schema, and guessing wrong on a paid resource is payment-without-delivery. *Compatibility:* absent means the legacy mapping, byte-for-byte; no consumer changes. When present, only named groups are forwarded - unmapped top-level input never reaches the backend. `validateBackendRequestShape` resolves both modes through the same function, so every shape error (missing/invalid path parameter, non-object group, query collision with the configured URL) is still an `INPUT_INVALID` raised before pricing. +- **Additive:** `PROTOCOL_NAMES`, the `ProtocolName` values as a runtime array. *Use case:* config validation and the OpenAPI importer's `--expose` both have to check a protocol name at runtime, and config was carrying its own hardcoded `new Set(['http','mcp','a2a'])`. *Alternative considered:* deriving `ProtocolName` from the array instead; rejected because it makes the surface printer expand the type into a literal union at every use site, turning a no-op into a noisy contract diff. *Compatibility:* additive value export, typed `readonly ProtocolName[]` so an unsupported name cannot enter it. No consumer changes. --- # Integration contract - exact factory signatures diff --git a/package.json b/package.json index ed75bed..779ca36 100644 --- a/package.json +++ b/package.json @@ -83,7 +83,7 @@ "demo:agent": "tsx demo/agent/src/main.ts", "demo:up": "docker compose up --build", "demo:down": "docker compose down -v", - "test:cli:dist": "node dist/cli/index.js --help && node dist/cli/index.js --version && node dist/cli/index.js version | grep -q '@x402/core' && node dist/cli/index.js version | head -1 >/dev/null", + "test:cli:dist": "node dist/cli/index.js --help && node dist/cli/index.js --version && node dist/cli/index.js version | grep -q '@x402/core' && node dist/cli/index.js version | head -1 >/dev/null && node dist/cli/index.js import openapi --help >/dev/null", "pack:dry": "npm pack --dry-run" }, "dependencies": { diff --git a/src/cli/commands/import-openapi.ts b/src/cli/commands/import-openapi.ts new file mode 100644 index 0000000..c7aa239 --- /dev/null +++ b/src/cli/commands/import-openapi.ts @@ -0,0 +1,234 @@ +/** + * `agent-commerce import openapi `. + * + * Generates reviewable resource drafts from a local OpenAPI description. It + * never touches config.yaml and never invents commerce policy: without + * `--free` / `--expose` the generated file is deliberately incomplete, so a + * human has to decide what an operation costs and who can see it before + * anything can load. + */ +import { existsSync } from 'node:fs'; +import { rename, rm, writeFile } from 'node:fs/promises'; +import { basename, extname, resolve } from 'node:path'; +import { PROTOCOL_NAMES } from '../../core/index.js'; +import { + buildResourceDrafts, + type ImportPolicy, + type ImportResult, + loadOpenApiDocument, + renderResourcesYaml, +} from '../../openapi/index.js'; +import type { Io } from '../lib/io.js'; + +export interface ImportOpenApiOptions { + readonly source: string; + readonly output?: string; + readonly force?: boolean; + readonly baseUrl?: string; + readonly operations?: readonly string[]; + readonly tags?: readonly string[]; + readonly free?: boolean; + readonly expose?: string; + readonly strict?: boolean; + readonly json?: boolean; +} + +export interface ImportOpenApiDeps { + readonly fileExists?: (path: string) => boolean; + readonly writeFile?: (path: string, content: string) => Promise; +} + +/** `.agent-commerce.yaml`, in the working directory. */ +export function defaultOutputPath(source: string): string { + const name = basename(source); + const stem = name.slice(0, name.length - extname(name).length) || name; + return `${stem}.agent-commerce.yaml`; +} + +export async function runImportOpenApi( + options: ImportOpenApiOptions, + io: Io, + deps: ImportOpenApiDeps = {}, +): Promise { + const fileExists = deps.fileExists ?? existsSync; + const outputPath = options.output ?? defaultOutputPath(options.source); + + let policy: ImportPolicy; + try { + policy = buildPolicy(options); + } catch (error) { + io.stderr(`FAIL ${error instanceof Error ? error.message : String(error)}`); + return 1; + } + + // Before doing any work: an existing file the operator did not ask to + // replace is a stop, not something to discover after the import ran. + if (fileExists(outputPath) && options.force !== true) { + io.stderr(`FAIL ${outputPath} already exists. Re-run with --force to overwrite.`); + return 1; + } + + let result: ImportResult; + try { + const loaded = await loadOpenApiDocument(options.source); + result = buildResourceDrafts(loaded, { + ...(options.baseUrl !== undefined ? { baseUrl: options.baseUrl } : {}), + ...(Object.keys(policy).length > 0 ? { policy } : {}), + ...(options.operations !== undefined || options.tags !== undefined + ? { + include: { + ...(options.operations !== undefined ? { operationIds: options.operations } : {}), + ...(options.tags !== undefined ? { tags: options.tags } : {}), + }, + } + : {}), + }); + } catch (error) { + io.stderr(`FAIL ${error instanceof Error ? error.message : String(error)}`); + return 1; + } + + const skipped = result.diagnostics.filter((entry) => entry.severity === 'error'); + const warnings = result.diagnostics.filter((entry) => entry.severity === 'warning'); + + const failures: string[] = []; + if (result.unmatchedOperationIds.length > 0) { + failures.push(`no operation matched ${result.unmatchedOperationIds.join(', ')}`); + } + if (result.drafts.length === 0) { + failures.push('no supported operations were imported'); + } + if (options.strict === true && warnings.length > 0) { + failures.push(`${warnings.length} warning(s) with --strict`); + } + + // Nothing is written when the run failed: a half-useful file that the next + // command silently picks up is worse than no file. + const wrote = failures.length === 0; + if (wrote) { + try { + await writeAtomically(outputPath, renderResourcesYaml(result), deps); + } catch (error) { + io.stderr(`FAIL ${outputPath} could not be written: ${describe(error)}`); + return 1; + } + } + + if (options.json === true) { + io.stdout( + JSON.stringify( + { + source: options.source, + openapi: result.version, + output: wrote ? outputPath : null, + imported: result.drafts.length, + skipped: skipped.length, + warnings: warnings.length, + resources: result.drafts.map((draft) => ({ + id: draft.id, + source: draft.source, + ...(draft.operationId !== undefined ? { operationId: draft.operationId } : {}), + tags: draft.tags, + })), + diagnostics: result.diagnostics, + unmatchedOperationIds: result.unmatchedOperationIds, + exitCode: failures.length === 0 ? 0 : 1, + }, + null, + 2, + ), + ); + } else { + printSummary(result, { outputPath, wrote, policy }, io); + } + + for (const failure of failures) io.stderr(`FAIL ${failure}`); + return failures.length === 0 ? 0 : 1; +} + +function buildPolicy(options: ImportOpenApiOptions): ImportPolicy { + const policy: { pricing?: Record; expose?: readonly string[] } = {}; + if (options.free === true) policy.pricing = { type: 'free' }; + if (options.expose !== undefined) { + const requested = options.expose + .split(',') + .map((name) => name.trim()) + .filter((name) => name !== ''); + if (requested.length === 0) { + throw new Error(`--expose needs at least one protocol (${PROTOCOL_NAMES.join(', ')})`); + } + for (const name of requested) { + if (!PROTOCOL_NAMES.includes(name as (typeof PROTOCOL_NAMES)[number])) { + throw new Error( + `--expose "${name}" is not a supported protocol. Supported: ${PROTOCOL_NAMES.join(', ')}`, + ); + } + } + policy.expose = requested; + } + return policy; +} + +async function writeAtomically( + outputPath: string, + content: string, + deps: ImportOpenApiDeps, +): Promise { + if (deps.writeFile !== undefined) { + await deps.writeFile(outputPath, content); + return; + } + // Temp sibling then rename: a crash or a full disk must not leave a + // half-written file where a complete one used to be. + const target = resolve(outputPath); + const temporary = `${target}.${process.pid}.tmp`; + try { + await writeFile(temporary, content, 'utf8'); + await rename(temporary, target); + } catch (error) { + await rm(temporary, { force: true }); + throw error; + } +} + +function printSummary( + result: ImportResult, + context: { outputPath: string; wrote: boolean; policy: ImportPolicy }, + io: Io, +): void { + const skipped = result.diagnostics.filter((entry) => entry.severity === 'error'); + const warnings = result.diagnostics.filter((entry) => entry.severity === 'warning'); + + io.stdout(`OpenAPI ${result.version}: ${result.sourcePath}`); + io.stdout(''); + io.stdout(`Imported: ${result.drafts.length}`); + io.stdout(`Skipped: ${skipped.length}`); + io.stdout(`Warnings: ${warnings.length}`); + + if (skipped.length > 0) { + io.stdout(''); + io.stdout('Skipped operations:'); + for (const entry of skipped) io.stdout(` ${entry.message}`); + } + if (warnings.length > 0) { + io.stdout(''); + io.stdout('Warnings:'); + for (const entry of warnings) io.stdout(` ${entry.message}`); + } + + if (context.wrote) { + io.stdout(''); + io.stdout('Generated:'); + io.stdout(` ${context.outputPath}`); + } + + io.stdout(''); + if (context.policy.pricing === undefined || context.policy.expose === undefined) { + io.stdout('Pricing/exposure were not inferred.'); + } + io.stdout('Review the generated resources before merging them into config.yaml.'); +} + +function describe(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/cli/program.ts b/src/cli/program.ts index d439f7f..5bee28c 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -1,7 +1,9 @@ import { Command } from 'commander'; import { DEFAULT_CONFIG_FILENAME } from '../config/filename.js'; +import { PROTOCOL_NAMES } from '../core/index.js'; import { runDemo } from './commands/demo.js'; import { printDoctorReport, runDoctor } from './commands/doctor.js'; +import { runImportOpenApi } from './commands/import-openapi.js'; import { runInit } from './commands/init.js'; import { runValidate } from './commands/validate.js'; import { runVersion } from './commands/version.js'; @@ -21,7 +23,7 @@ export function buildProgram(io: Io = processIo): Command { program .name('agent-commerce') - .description('CLI for the Agent Commerce Gateway: init, validate, doctor, demo.') + .description('CLI for the Agent Commerce Gateway: init, import, validate, doctor, demo.') // `--version` is required of the published binary. The `version` // subcommand stays: it additionally prints the pinned protocol/SDK // versions, which is what `doctor` and the support matrix are checked @@ -81,6 +83,63 @@ export function buildProgram(io: Io = processIo): Command { process.exitCode = code; }); + // `import` is a group so a future `import postman`/`import graphql` is a + // sibling rather than a rename of an established command. + const importCommand = program + .command('import') + .description('Generate Agent Commerce resource drafts from an API description.'); + + importCommand + .command('openapi') + .description('Convert a local OpenAPI 3.0/3.1/3.2 document into resource drafts.') + .argument('', 'path to a local .yaml, .yml or .json OpenAPI document') + .option('--output ', 'output path (default: .agent-commerce.yaml)') + .option('--force', 'overwrite an existing output file', false) + .option('--base-url ', 'backend base URL, overriding the document servers') + .option( + '--operation ', + 'import only this operation (repeatable)', + collect, + undefined, + ) + .option('--tag ', 'import only operations with this tag (repeatable, OR-ed)', collect) + .option('--free', 'mark generated resources as pricing.type free', false) + .option('--expose ', `comma-separated: ${PROTOCOL_NAMES.join(',')}`) + .option('--strict', 'exit non-zero when the import produced warnings', false) + .option('--json', 'emit a machine-readable summary instead of a report', false) + .action( + async ( + source: string, + opts: { + output?: string; + force: boolean; + baseUrl?: string; + operation?: string[]; + tag?: string[]; + free: boolean; + expose?: string; + strict: boolean; + json: boolean; + }, + ) => { + process.exitCode = await runImportOpenApi( + { + source, + ...(opts.output !== undefined ? { output: opts.output } : {}), + force: opts.force, + ...(opts.baseUrl !== undefined ? { baseUrl: opts.baseUrl } : {}), + ...(opts.operation !== undefined ? { operations: opts.operation } : {}), + ...(opts.tag !== undefined ? { tags: opts.tag } : {}), + free: opts.free, + ...(opts.expose !== undefined ? { expose: opts.expose } : {}), + strict: opts.strict, + json: opts.json, + }, + io, + ); + }, + ); + program .command('demo') .description( @@ -92,3 +151,8 @@ export function buildProgram(io: Io = processIo): Command { return program; } + +/** Commander's repeatable-option collector. */ +function collect(value: string, previous: string[] | undefined): string[] { + return [...(previous ?? []), value]; +} diff --git a/src/config/schema.ts b/src/config/schema.ts index f42d988..699dbfb 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -34,6 +34,7 @@ import { CommerceError, type CommerceResource, PAYMENT_INPUT_FIELD, + PROTOCOL_NAMES, type Pricing, } from '../core/index.js'; import { resolveX402Deployment, type X402FacilitatorConfig } from '../payments/x402/guardrails.js'; @@ -497,7 +498,7 @@ function toBoolean(value: boolean | string, path: string): boolean { // Business-rule validation + normalisation into the canonical shape. // --------------------------------------------------------------------------- -const SUPPORTED_PROTOCOLS = new Set(['http', 'mcp', 'a2a']); +const SUPPORTED_PROTOCOLS: ReadonlySet = new Set(PROTOCOL_NAMES); const SUPPORTED_PAYMENT_METHODS = new Set(['x402']); function normalise(raw: RawConfig): GatewayConfig { diff --git a/src/core/domain/common.ts b/src/core/domain/common.ts index 34c3716..245e121 100644 --- a/src/core/domain/common.ts +++ b/src/core/domain/common.ts @@ -14,9 +14,24 @@ */ export type JsonSchema = Record; -/** Protocol surfaces a resource can be exposed through in this release. */ +/** + * Protocol surfaces a resource can be exposed through in this release. + * + * The runtime list is the definition and the type is derived from it, so a + * caller that has to *check* a name (config validation, the OpenAPI + * importer's `--expose`) reads the same three strings the type is built from + * rather than keeping a second copy that can drift. + */ export type ProtocolName = 'http' | 'mcp' | 'a2a'; +/** + * The same three names as a value, for code that has to *check* one at + * runtime - config validation, the OpenAPI importer's `--expose`. Typed + * against `ProtocolName` so an unsupported name cannot enter the list, which + * is what keeps this from becoming a second definition that drifts. + */ +export const PROTOCOL_NAMES: readonly ProtocolName[] = ['http', 'mcp', 'a2a']; + /** Payment methods a resource can accept in this release. */ export type PaymentMethodName = 'x402'; diff --git a/src/core/public-types.ts b/src/core/public-types.ts index 1ee5a43..ff692cd 100644 --- a/src/core/public-types.ts +++ b/src/core/public-types.ts @@ -24,6 +24,7 @@ export type { PaymentMethodName, ProtocolName, } from './domain/common.js'; +export { PROTOCOL_NAMES } from './domain/common.js'; export type { CommerceEvent, CommerceEventType, EventSink } from './domain/event.js'; export { COMMERCE_EVENT_TYPES } from './domain/event.js'; diff --git a/tests/unit/cli/import-openapi.test.ts b/tests/unit/cli/import-openapi.test.ts new file mode 100644 index 0000000..c1833b3 --- /dev/null +++ b/tests/unit/cli/import-openapi.test.ts @@ -0,0 +1,217 @@ +import { existsSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { parse as parseYaml } from 'yaml'; +import { defaultOutputPath, runImportOpenApi } from '../../../src/cli/commands/import-openapi.js'; +import { createCapturingIo } from '../../../src/cli/lib/io.js'; +import { buildProgram } from '../../../src/cli/program.js'; + +const fixture = (name: string): string => + join(fileURLToPath(new URL('../openapi/fixtures/', import.meta.url)), name); + +function tmpOutput(name = 'out.yaml'): string { + return join(mkdtempSync(join(tmpdir(), 'agent-commerce-import-')), name); +} + +async function run(options: Parameters[0]) { + const io = createCapturingIo(); + const code = await runImportOpenApi(options, io); + return { code, io, out: io.out.join('\n'), err: io.err.join('\n') }; +} + +describe('agent-commerce import openapi', () => { + it('appears in --help with its options', async () => { + const io = createCapturingIo(); + const program = buildProgram(io); + await expect( + program.parseAsync(['node', 'agent-commerce', 'import', 'openapi', '--help']), + ).rejects.toThrow(); + const help = io.out.join('\n'); + expect(help).toContain('--base-url'); + expect(help).toContain('--operation'); + expect(help).toContain('--tag'); + expect(help).toContain('--free'); + expect(help).toContain('--expose'); + expect(help).toContain('--strict'); + expect(help).toContain('--json'); + }); + + it('imports a YAML document and writes a reviewable fragment', async () => { + const output = tmpOutput(); + const { code, out } = await run({ source: fixture('responses-3.1.yaml'), output }); + + expect(code).toBe(0); + expect(out).toContain('Imported: 5'); + expect(out).toContain('Pricing/exposure were not inferred.'); + const written = readFileSync(output, 'utf8'); + expect(written).toContain('# Generated by agent-commerce import openapi'); + const parsed = parseYaml(written) as { resources: Record }; + expect(Object.keys(parsed.resources)).toContain('getOrder'); + }); + + it('imports a JSON document', async () => { + const output = tmpOutput(); + const { code } = await run({ source: fixture('minimal-3.1.json'), output }); + expect(code).toBe(0); + expect(readFileSync(output, 'utf8')).toContain('list_things:'); + }); + + it('defaults the output name to .agent-commerce.yaml', () => { + expect(defaultOutputPath('./specs/openapi.yaml')).toBe('openapi.agent-commerce.yaml'); + expect(defaultOutputPath('/tmp/petstore.json')).toBe('petstore.agent-commerce.yaml'); + }); + + it('refuses to overwrite an existing file, and leaves it untouched', async () => { + const output = tmpOutput(); + writeFileSync(output, 'keep me\n', 'utf8'); + + const { code, err } = await run({ source: fixture('responses-3.1.yaml'), output }); + + expect(code).toBe(1); + expect(err).toContain('already exists'); + expect(readFileSync(output, 'utf8')).toBe('keep me\n'); + }); + + it('overwrites with --force', async () => { + const output = tmpOutput(); + writeFileSync(output, 'keep me\n', 'utf8'); + const { code } = await run({ source: fixture('responses-3.1.yaml'), output, force: true }); + expect(code).toBe(0); + expect(readFileSync(output, 'utf8')).toContain('resources:'); + }); + + it('leaves no temporary file behind', async () => { + const output = tmpOutput(); + await run({ source: fixture('responses-3.1.yaml'), output }); + const dir = join(output, '..'); + expect(readdirSync(dir)).toEqual(['out.yaml']); + }); + + it('applies --base-url over the document servers', async () => { + const output = tmpOutput(); + const { code } = await run({ + source: fixture('relative-server.yaml'), + output, + baseUrl: 'https://backend.internal', + }); + expect(code).toBe(0); + expect(readFileSync(output, 'utf8')).toContain('url: https://backend.internal/a'); + }); + + it('filters by --operation and fails on one that matched nothing', async () => { + const output = tmpOutput(); + const kept = await run({ + source: fixture('responses-3.1.yaml'), + output, + operations: ['getOrder'], + }); + expect(kept.code).toBe(0); + const parsed = parseYaml(readFileSync(output, 'utf8')) as { + resources: Record; + }; + expect(Object.keys(parsed.resources)).toEqual(['getOrder']); + + const missing = await run({ + source: fixture('responses-3.1.yaml'), + output: tmpOutput(), + operations: ['getOrder', 'nope'], + }); + expect(missing.code).toBe(1); + expect(missing.err).toContain('nope'); + }); + + it('filters by --tag', async () => { + const output = tmpOutput(); + await run({ source: fixture('responses-3.1.yaml'), output, tags: ['write'] }); + const parsed = parseYaml(readFileSync(output, 'utf8')) as { + resources: Record; + }; + expect(Object.keys(parsed.resources)).toEqual(['cancelOrder']); + }); + + it('writes pricing only with --free and exposure only with --expose', async () => { + const output = tmpOutput(); + const { out } = await run({ + source: fixture('responses-3.1.yaml'), + output, + free: true, + expose: 'http, mcp', + }); + const parsed = parseYaml(readFileSync(output, 'utf8')) as { + resources: Record; + }; + expect(parsed.resources['getOrder']?.pricing).toEqual({ type: 'free' }); + expect(parsed.resources['getOrder']?.expose).toEqual(['http', 'mcp']); + expect(out).not.toContain('Pricing/exposure were not inferred.'); + }); + + it('rejects an unsupported --expose protocol before reading the document', async () => { + const output = tmpOutput(); + const { code, err } = await run({ + source: fixture('responses-3.1.yaml'), + output, + expose: 'http,htpp', + }); + expect(code).toBe(1); + expect(err).toContain('htpp'); + expect(err).toContain('http, mcp, a2a'); + expect(existsSync(output)).toBe(false); + }); + + it('turns warnings into a non-zero exit under --strict, writing nothing', async () => { + const output = tmpOutput(); + const { code, err } = await run({ + source: fixture('responses-3.1.yaml'), + output, + strict: true, + }); + expect(code).toBe(1); + expect(err).toContain('--strict'); + expect(existsSync(output)).toBe(false); + }); + + it('emits a machine-readable summary with --json', async () => { + const output = tmpOutput(); + const { code, out } = await run({ + source: fixture('responses-3.1.yaml'), + output, + json: true, + }); + expect(code).toBe(0); + const summary = JSON.parse(out) as { + openapi: string; + imported: number; + warnings: number; + output: string; + resources: { id: string }[]; + diagnostics: { code: string }[]; + exitCode: number; + }; + expect(summary.openapi).toBe('3.1'); + expect(summary.imported).toBe(5); + expect(summary.output).toBe(output); + expect(summary.resources.map((r) => r.id)).toContain('cancelOrder'); + expect(summary.diagnostics.some((d) => d.code === 'backend-authentication-required')).toBe( + true, + ); + expect(summary.exitCode).toBe(0); + }); + + it('fails when no supported operation could be imported', async () => { + const output = tmpOutput(); + const { code, err } = await run({ source: fixture('relative-server.yaml'), output }); + expect(code).toBe(1); + expect(err).toContain('no supported operations'); + expect(existsSync(output)).toBe(false); + }); + + it('reports an unreadable or invalid document without writing anything', async () => { + const output = tmpOutput(); + const { code, err } = await run({ source: fixture('swagger-2.0.yaml'), output }); + expect(code).toBe(1); + expect(err).toContain('Swagger 2.0'); + expect(existsSync(output)).toBe(false); + }); +}); diff --git a/tests/unit/cli/packaging.test.ts b/tests/unit/cli/packaging.test.ts index 74490b4..8e1b590 100644 --- a/tests/unit/cli/packaging.test.ts +++ b/tests/unit/cli/packaging.test.ts @@ -249,11 +249,21 @@ describe.skipIf(!built)('built executable', () => { it('offers every documented command', () => { const help = run('--help'); - for (const command of ['init', 'validate', 'doctor', 'demo', 'version']) { + for (const command of ['init', 'import', 'validate', 'doctor', 'demo', 'version']) { expect(help).toContain(command); } }); + it('runs the openapi importer from the built binary', () => { + // The importer is the one command with a non-peer runtime dependency of + // its own (@scalar/openapi-parser). A bundling mistake there shows up + // only here: the source tests import the module directly and would stay + // green while the published binary failed to resolve it. + const help = run('import', 'openapi', '--help'); + expect(help).toContain('--expose'); + expect(help).toContain('--base-url'); + }); + it('imports nothing from the repo source tree', () => { const source = readFileSync(distEntry, 'utf8'); expect(source).not.toMatch(/from\s*['"][^'"]*packages\/(core|config|gateway)\//); From 666fcd0d184e2b53dbe9c523fa1a79023f1d2aee Mon Sep 17 00:00:00 2001 From: Revinand Date: Fri, 4 Sep 2026 13:15:46 +0200 Subject: [PATCH 7/8] test(openapi): add cross-protocol import integration coverage --- src/openapi/discover.ts | 11 +- .../fixtures/merchant-api.openapi.yaml | 182 +++++++ tests/integration/openapi-import.test.ts | 514 ++++++++++++++++++ tests/unit/openapi/discover.test.ts | 11 + 4 files changed, 716 insertions(+), 2 deletions(-) create mode 100644 tests/integration/fixtures/merchant-api.openapi.yaml create mode 100644 tests/integration/openapi-import.test.ts diff --git a/src/openapi/discover.ts b/src/openapi/discover.ts index 6804527..affc2e4 100644 --- a/src/openapi/discover.ts +++ b/src/openapi/discover.ts @@ -269,10 +269,17 @@ function substituteServerVariables( return substituted; } +/** + * An absolute http(s) origin plus path, and nothing after it. A query or + * fragment on the base would land *before* the operation path once the two + * are concatenated (`.../api?key=x` + `/users/{id}`), producing a URL that + * parses fine and calls the wrong endpoint. + */ function isAbsoluteHttpUrl(value: string): boolean { try { const parsed = new URL(value); - return parsed.protocol === 'http:' || parsed.protocol === 'https:'; + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false; + return parsed.search === '' && parsed.hash === ''; } catch { return false; } @@ -282,7 +289,7 @@ function assertAbsoluteHttpUrl(value: string, label: string): void { if (!isAbsoluteHttpUrl(value)) { throw new CommerceError( 'CONFIG_INVALID', - `${label} "${value}" must be an absolute http:// or https:// URL`, + `${label} "${value}" must be an absolute http:// or https:// URL with no query string or fragment`, { details: { value } }, ); } diff --git a/tests/integration/fixtures/merchant-api.openapi.yaml b/tests/integration/fixtures/merchant-api.openapi.yaml new file mode 100644 index 0000000..8dffaa3 --- /dev/null +++ b/tests/integration/fixtures/merchant-api.openapi.yaml @@ -0,0 +1,182 @@ +openapi: 3.1.0 +info: + title: Demo Merchant API + version: 1.0.0 +servers: + - url: https://api.merchant.example/v1 +security: + - apiKey: [] +paths: + /users/{userId}/orders: + parameters: + - name: userId + in: path + required: true + schema: + type: string + get: + operationId: listOrders + summary: List orders + security: [] + parameters: + - name: status + in: query + schema: + type: string + responses: + '200': + description: ok + content: + application/json: + schema: + type: object + properties: + orders: + type: array + items: + $ref: '#/components/schemas/Order' + post: + operationId: createOrder + summary: Create an order + security: [] + parameters: + - name: notify + in: query + required: true + schema: + type: boolean + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/NewOrder' + responses: + '201': + description: created + content: + application/json: + schema: + $ref: '#/components/schemas/Order' + /reports/{reportId}: + get: + operationId: getReport + summary: Premium report + security: [] + parameters: + - name: reportId + in: path + required: true + schema: + type: string + responses: + '200': + description: ok + content: + application/json: + schema: + type: object + properties: + report: + type: string + /search: + get: + operationId: search + summary: Search + security: [] + parameters: + - name: q + in: query + required: true + schema: + type: string + - name: tags + in: query + schema: + type: array + items: + type: string + responses: + '200': + description: ok + content: + application/json: + schema: + type: object + properties: + hits: + type: integer + /uploads: + post: + operationId: upload + summary: Upload a file + security: [] + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + file: + type: string + responses: + '201': + description: created + /tenant-report: + get: + operationId: tenantReport + summary: Per-tenant report + security: [] + parameters: + - name: X-Tenant + in: header + required: true + schema: + type: string + responses: + '200': + description: ok + content: + application/json: + schema: + type: object + properties: + tenant: + type: string + /audit: + get: + operationId: audit + summary: Audit log + responses: + '200': + description: ok + content: + application/json: + schema: + type: object + properties: + entries: + type: integer +components: + securitySchemes: + apiKey: + type: apiKey + name: X-Api-Key + in: header + schemas: + NewOrder: + type: object + properties: + productId: + type: string + quantity: + type: integer + required: [productId] + Order: + type: object + properties: + id: + type: string + productId: + type: string diff --git a/tests/integration/openapi-import.test.ts b/tests/integration/openapi-import.test.ts new file mode 100644 index 0000000..c5650b9 --- /dev/null +++ b/tests/integration/openapi-import.test.ts @@ -0,0 +1,514 @@ +/** + * OpenAPI import -> config -> gateway -> merchant backend. + * + * The point of this suite is that an imported resource is not a parallel + * feature. It goes through the real CLI command, the real config parser, the + * real gateway with real MCP and A2A adapters, and the real + * `HttpBackendExecutor` calling a real (local) merchant server - so the + * request the merchant receives is the one the gateway actually built, not one + * a fake executor agreed to. Nothing here injects importer internals into the + * pipeline. + */ + +import { mkdtempSync, readFileSync } from 'node:fs'; +import { createServer, type Server } from 'node:http'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import { parse as parseYaml } from 'yaml'; +import { runImportOpenApi } from '../../src/cli/commands/import-openapi.js'; +import { createCapturingIo } from '../../src/cli/lib/io.js'; +import { parseConfig } from '../../src/config/index.js'; +import { + type AdapterDescriptor, + PAYMENT_HEADER, + type PaymentContext, + type PaymentProvider, + type PaymentRequirement, + type PaymentResult, + type PaymentVerificationContext, +} from '../../src/core/index.js'; +import { createGateway, type GatewayInstance } from '../../src/gateway/index.js'; +import { createA2aAdapter } from '../../src/protocols/a2a/index.js'; +import { createMcpAdapter } from '../../src/protocols/mcp/index.js'; +import { createFakeStore } from '../unit/gateway/helpers.js'; + +process.env['NODE_ENV'] = 'test'; + +const SPEC = fileURLToPath(new URL('./fixtures/merchant-api.openapi.yaml', import.meta.url)); + +/** Exactly what the merchant backend saw. */ +interface InboundRequest { + method: string; + path: string; + query: Record; + headers: Record; + body: unknown; +} + +let merchant: Server; +let merchantUrl: string; +let inbound: InboundRequest[] = []; + +beforeAll(async () => { + merchant = createServer((req, res) => { + const chunks: Buffer[] = []; + req.on('data', (chunk: Buffer) => chunks.push(chunk)); + req.on('end', () => { + const url = new URL(req.url ?? '/', 'http://merchant.local'); + const raw = Buffer.concat(chunks).toString('utf8'); + inbound.push({ + method: req.method ?? '', + path: url.pathname, + query: Object.fromEntries(url.searchParams.entries()), + headers: req.headers, + body: raw === '' ? undefined : safeJson(raw), + }); + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ ok: true, path: url.pathname })); + }); + }); + await new Promise((resolve) => merchant.listen(0, '127.0.0.1', resolve)); + const address = merchant.address(); + if (address === null || typeof address === 'string') throw new Error('no merchant address'); + merchantUrl = `http://127.0.0.1:${address.port}`; +}); + +afterAll(async () => { + await new Promise((resolve) => merchant.close(() => resolve())); +}); + +function safeJson(raw: string): unknown { + try { + return JSON.parse(raw); + } catch { + return raw; + } +} + +/** Runs the real CLI command and returns the parsed `resources:` fragment. */ +async function importResources( + extra: Partial[0]> = {}, +): Promise>> { + const output = join(mkdtempSync(join(tmpdir(), 'oac-import-integration-')), 'resources.yaml'); + const io = createCapturingIo(); + const code = await runImportOpenApi( + { + source: SPEC, + output, + baseUrl: merchantUrl, + free: true, + expose: 'http,mcp,a2a', + ...extra, + }, + io, + ); + expect(code, io.err.join('\n')).toBe(0); + const parsed = parseYaml(readFileSync(output, 'utf8')) as { + resources: Record>; + }; + return parsed.resources; +} + +const descriptor: AdapterDescriptor = { + name: 'fake-x402', + kind: 'payment', + implementationVersion: '0.0.0-test', + supportedSpec: 'n/a', + capabilities: [], + status: 'experimental', +}; + +function createFakeX402Provider(): PaymentProvider & { verified: number; settled: number } { + const provider = { + verified: 0, + settled: 0, + name: 'x402' as const, + descriptor, + createRequirement: async (ctx: PaymentContext): Promise => ({ + id: 'req-1', + requestId: ctx.requestId, + resourceId: ctx.resource.id, + provider: 'x402', + amount: ctx.amount, + currency: ctx.currency, + destination: '0xMERCHANT', + challenge: { provider: 'x402', version: '1', accepts: [{ scheme: 'exact' }] }, + }), + verify: async (ctx: PaymentVerificationContext): Promise => { + provider.verified += 1; + return { + status: ctx.submission.payload === 'valid-proof' ? 'verified' : 'rejected', + provider: 'x402', + amount: '0.01', + currency: 'USDC', + replayKey: `replay-${String(ctx.submission.payload)}-${provider.verified}`, + }; + }, + settle: async (): Promise => { + provider.settled += 1; + return { + status: 'settled', + provider: 'x402', + amount: '0.01', + currency: 'USDC', + externalReference: '0xTXHASH', + }; + }, + health: async () => ({ status: 'pass' as const, checkedAt: '2026-01-01T00:00:00.000Z' }), + }; + return provider; +} + +/** Mirrors the x402 block the other integration suites use; the provider itself is a fake. */ +const X402_CONFIG = { + enabled: true, + network: 'eip155:84532', + rpcUrl: 'http://127.0.0.1:8545', + asset: '0x1111111111111111111111111111111111111111', + assetName: 'MockUSDC', + assetVersion: '2', + assetDecimals: 6, + payTo: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', + maxTimeoutSeconds: 120, + facilitator: { mode: 'local', signerPrivateKey: '0xTEST_ONLY_NOT_A_REAL_KEY' }, +}; + +function rawConfig( + resources: Record, + withPayments = false, +): Record { + return { + version: 1, + merchant: { id: 'demo-store', name: 'Demo Store', publicBaseUrl: 'http://localhost:8080' }, + server: { port: 0, host: '127.0.0.1' }, + storage: { receipts: { driver: 'sqlite', path: ':memory:' } }, + protocols: { + http: { enabled: true }, + mcp: { enabled: true, mountPath: '/mcp' }, + a2a: { enabled: true, mountPath: '/a2a' }, + }, + resources, + payments: withPayments ? { x402: X402_CONFIG } : {}, + }; +} + +let gateway: GatewayInstance | undefined; +let client: Client | undefined; + +afterEach(async () => { + await client?.close().catch(() => {}); + await gateway?.close().catch(() => {}); + client = undefined; + gateway = undefined; + inbound = []; +}); + +async function startGateway( + resources: Record, + paymentProviders: PaymentProvider[] = [], +): Promise { + gateway = await createGateway({ + config: parseConfig(rawConfig(resources, paymentProviders.length > 0), {}), + store: createFakeStore(), + paymentProviders, + protocolAdapters: [createMcpAdapter(), createA2aAdapter()], + // No `backend` override: the real HttpBackendExecutor builds the request. + }); + return gateway; +} + +function invoke( + gw: GatewayInstance, + id: string, + input: unknown, + headers: Record = {}, +) { + return gw.server.inject({ + method: 'POST', + url: `/api/resources/${id}/invoke`, + headers: { 'content-type': 'application/json', ...headers }, + payload: JSON.stringify(input), + }); +} + +function sendMessage(resource: string, input: unknown): string { + return JSON.stringify({ + jsonrpc: '2.0', + id: 'req-1', + method: 'SendMessage', + params: { + message: { + role: 'ROLE_USER', + messageId: 'msg-1', + parts: [{ data: { resource, input }, mediaType: 'application/json' }], + }, + }, + }); +} + +const CREATE_ORDER_INPUT = { + path: { userId: 'u-1' }, + query: { notify: true }, + body: { productId: 'sku-9', quantity: 2 }, +}; + +describe('imported resources over the real gateway', () => { + let resources: Record>; + + beforeAll(async () => { + resources = await importResources(); + }); + + it('imports the supported operations and skips the unsupported one', () => { + expect(Object.keys(resources).sort()).toEqual([ + 'audit', + 'createOrder', + 'getReport', + 'listOrders', + 'search', + ]); + // Neither a required multipart body nor a required header parameter + // produces a runnable resource - approximating either would take payment + // for a request the merchant cannot serve. + expect(resources).not.toHaveProperty('upload'); + expect(resources).not.toHaveProperty('tenantReport'); + // The optional array query parameter was dropped, the required one kept. + const search = resources['search']?.['input'] as { + properties: { query: { properties: object } }; + }; + expect(Object.keys(search.properties.query.properties)).toEqual(['q']); + }); + + it('sends path, query and body to their own places on one POST', async () => { + const gw = await startGateway(resources); + + const res = await invoke(gw, 'createOrder', CREATE_ORDER_INPUT); + + expect(res.statusCode).toBe(200); + expect(inbound).toHaveLength(1); + const request = inbound[0]; + expect(request?.method).toBe('POST'); + expect(request?.path).toBe('/users/u-1/orders'); + expect(request?.query).toEqual({ notify: 'true' }); + // The regression this whole binding feature exists to prevent: `notify` + // must not have ended up inside the JSON body. + expect(request?.body).toEqual({ productId: 'sku-9', quantity: 2 }); + expect(request?.headers['content-type']).toBe('application/json'); + }); + + it('produces the identical merchant request over HTTP, MCP and A2A', async () => { + const gw = await startGateway(resources); + const { url } = await gw.listen(); + + await invoke(gw, 'createOrder', CREATE_ORDER_INPUT); + + client = new Client({ name: 'import-integration', version: '0.0.0-test' }); + await client.connect( + new StreamableHTTPClientTransport(new URL(`${url}/mcp`)) as unknown as Transport, + ); + const toolResult = await client.callTool({ + name: 'createOrder', + arguments: CREATE_ORDER_INPUT, + }); + expect((toolResult as { isError?: boolean }).isError).not.toBe(true); + + const a2a = await gw.server.inject({ + method: 'POST', + url: '/a2a', + headers: { 'content-type': 'application/json', 'a2a-version': '1.0' }, + payload: sendMessage('createOrder', CREATE_ORDER_INPUT), + }); + expect(a2a.statusCode).toBe(200); + expect(a2a.json().error).toBeUndefined(); + + expect(inbound).toHaveLength(3); + const shapes = inbound.map((request) => ({ + method: request.method, + path: request.path, + query: request.query, + body: request.body, + })); + expect(shapes[1]).toEqual(shapes[0]); + expect(shapes[2]).toEqual(shapes[0]); + }); + + it('exposes exactly the generated resources through protocol discovery', async () => { + const gw = await startGateway(resources); + const { url } = await gw.listen(); + + client = new Client({ name: 'import-integration', version: '0.0.0-test' }); + await client.connect( + new StreamableHTTPClientTransport(new URL(`${url}/mcp`)) as unknown as Transport, + ); + const tools = await client.listTools(); + expect(tools.tools.map((tool) => tool.name).sort()).toEqual(Object.keys(resources).sort()); + + const card = await gw.server.inject({ method: 'GET', url: '/.well-known/agent-card.json' }); + expect( + card + .json<{ skills: { id: string }[] }>() + .skills.map((skill) => skill.id) + .sort(), + ).toEqual(Object.keys(resources).sort()); + + // The skipped operation is not reachable by guessing its id either. + const missing = await invoke(gw, 'upload', {}); + expect(missing.statusCode).toBe(404); + }); + + it('keeps an imported paid resource on the normal payment path', async () => { + const provider = createFakeX402Provider(); + const paid = { + ...resources, + getReport: { + ...resources['getReport'], + pricing: { type: 'fixed', amount: '0.01', currency: 'USDC' }, + payments: ['x402'], + }, + }; + const gw = await startGateway(paid, [provider]); + + const challenge = await invoke(gw, 'getReport', { path: { reportId: 'r-1' } }); + expect(challenge.statusCode).toBe(402); + expect(challenge.json().code).toBe('PAYMENT_REQUIRED'); + expect(inbound).toHaveLength(0); + + const delivered = await invoke( + gw, + 'getReport', + { path: { reportId: 'r-1' } }, + { [PAYMENT_HEADER]: 'valid-proof' }, + ); + expect(delivered.statusCode).toBe(200); + expect(provider.settled).toBe(1); + expect(inbound.map((request) => request.path)).toEqual(['/reports/r-1']); + }); + + it('rejects a bad imported path value before any payment is taken', async () => { + const provider = createFakeX402Provider(); + const paid = { + ...resources, + getReport: { + ...resources['getReport'], + pricing: { type: 'fixed', amount: '0.01', currency: 'USDC' }, + payments: ['x402'], + }, + }; + const gw = await startGateway(paid, [provider]); + + const res = await invoke( + gw, + 'getReport', + { path: { reportId: '..' } }, + { [PAYMENT_HEADER]: 'valid-proof' }, + ); + + expect(res.statusCode).toBe(400); + expect(res.json().code).toBe('INPUT_INVALID'); + // The money invariant: nothing was verified, nothing settled, nothing sent. + expect(provider.verified).toBe(0); + expect(provider.settled).toBe(0); + expect(inbound).toHaveLength(0); + }); + + it('rejects a query collision with an operator-configured backend query before payment', async () => { + const provider = createFakeX402Provider(); + const listOrders = resources['listOrders'] as Record; + const backend = { ...(listOrders['backend'] as Record) }; + // The operator pins a query parameter the imported schema also carries. + backend['url'] = `${String(backend['url'])}?status=archived`; + const collided = { + ...resources, + listOrders: { + ...listOrders, + backend, + pricing: { type: 'fixed', amount: '0.01', currency: 'USDC' }, + payments: ['x402'], + }, + }; + const gw = await startGateway(collided, [provider]); + + const res = await invoke( + gw, + 'listOrders', + { path: { userId: 'u-1' }, query: { status: 'open' } }, + { [PAYMENT_HEADER]: 'valid-proof' }, + ); + + expect(res.statusCode).toBe(400); + expect(res.json().code).toBe('INPUT_INVALID'); + expect(provider.settled).toBe(0); + expect(inbound).toHaveLength(0); + }); + + it('cannot be steered at another host by path input', async () => { + const gw = await startGateway(resources); + + const res = await invoke(gw, 'listOrders', { path: { userId: 'http://evil.example/x' } }); + + expect(res.statusCode).toBe(200); + expect(inbound).toHaveLength(1); + // Encoded into one path segment of the configured host, not a new origin. + expect(inbound[0]?.path).toBe('/users/http%3A%2F%2Fevil.example%2Fx/orders'); + }); + + it('carries the security warning without importing any credential', async () => { + const io = createCapturingIo(); + const output = join(mkdtempSync(join(tmpdir(), 'oac-import-security-')), 'resources.yaml'); + await runImportOpenApi( + { source: SPEC, output, baseUrl: merchantUrl, free: true, expose: 'http' }, + io, + ); + + const written = readFileSync(output, 'utf8'); + expect(io.out.join('\n')).toContain('audit declares backend authentication'); + expect(written).toContain('No credential was imported'); + expect(written).not.toContain('X-Api-Key'); + expect(written).not.toContain('apiKey'); + }); + + it('refuses an external $ref without a single outbound request', async () => { + const fetchSpy = vi.fn(async () => { + throw new Error('the importer must not perform network requests'); + }); + vi.stubGlobal('fetch', fetchSpy); + try { + const io = createCapturingIo(); + const source = fileURLToPath( + new URL('../unit/openapi/fixtures/external-http-ref.yaml', import.meta.url), + ); + const code = await runImportOpenApi( + { source, output: join(mkdtempSync(join(tmpdir(), 'oac-import-ext-')), 'out.yaml') }, + io, + ); + expect(code).toBe(1); + expect(io.err.join('\n')).toContain('external reference'); + expect(fetchSpy).not.toHaveBeenCalled(); + } finally { + vi.unstubAllGlobals(); + } + }); + + it('behaves identically to the same resource written by hand', async () => { + const handWritten = { + name: 'Create an order', + input: resources['createOrder']?.['input'], + backend: resources['createOrder']?.['backend'], + pricing: { type: 'free' }, + expose: ['http'], + }; + const gw = await startGateway({ manual: handWritten, ...resources }); + + await invoke(gw, 'manual', CREATE_ORDER_INPUT); + await invoke(gw, 'createOrder', CREATE_ORDER_INPUT); + + expect(inbound).toHaveLength(2); + const [manual, imported] = inbound; + expect({ ...imported, headers: undefined }).toEqual({ ...manual, headers: undefined }); + }); +}); diff --git a/tests/unit/openapi/discover.test.ts b/tests/unit/openapi/discover.test.ts index df99d08..05b6f21 100644 --- a/tests/unit/openapi/discover.test.ts +++ b/tests/unit/openapi/discover.test.ts @@ -106,6 +106,17 @@ describe('discoverOperations', () => { await expect(discover('petstore-3.0.yaml', { baseUrl: '/v1' })).rejects.toThrowError(); }); + it('rejects a --base-url carrying a query string or fragment', async () => { + // Concatenation puts the operation path AFTER the query, so the resulting + // URL parses and calls the wrong endpoint. + await expect( + discover('petstore-3.0.yaml', { baseUrl: 'https://api.example.com/v1?apikey=SECRET' }), + ).rejects.toThrowError(); + await expect( + discover('petstore-3.0.yaml', { baseUrl: 'https://api.example.com/v1#frag' }), + ).rejects.toThrowError(); + }); + it('skips an operation whose only server URL is relative, and says to pass --base-url', async () => { const result = await discover('relative-server.yaml'); expect(result.operations).toHaveLength(0); From 73a296ef0f4082ce23687dfc82f99fdfcf0ca20f Mon Sep 17 00:00:00 2001 From: Revinand Date: Fri, 4 Sep 2026 15:13:09 +0200 Subject: [PATCH 8/8] docs(openapi): document import workflow and supported subset --- README.md | 28 +++-- docs/architecture.md | 32 ++++-- docs/configuration.md | 78 ++++++++++++-- docs/openapi-import.md | 226 +++++++++++++++++++++++++++++++++++++++++ docs/security.md | 93 +++++++++++++---- 5 files changed, 415 insertions(+), 42 deletions(-) create mode 100644 docs/openapi-import.md diff --git a/README.md b/README.md index f52deae..2d1b99b 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,8 @@ You already have an HTTP API. AI agents want to **discover** it, **call** it and **pay** for it - over protocols you did not write and do not want to maintain. Agent Commerce Gateway sits in front of your existing API, in **your** -infrastructure, and does that for you. You describe an endpoint in a YAML file; +infrastructure, and does that for you. You describe an endpoint in a YAML file - +or generate that description from an OpenAPI document you already have - and agents get an MCP tool and an x402 paywall. The money goes straight to your wallet - the gateway never holds it, and never holds your keys. @@ -207,6 +208,20 @@ npm run agent-commerce -- init # generate a config interactively npm run agent-commerce -- validate # fails loudly, exits non-zero ``` +Already have an OpenAPI description? Generate the resources from it +(**experimental**): + +```bash +agent-commerce import openapi ./openapi.yaml +``` + +It writes a reviewable `resources:` fragment - path, query and JSON body +mapped, schemas converted to what the gateway actually enforces - and +deliberately leaves `pricing` and `expose` out, because an OpenAPI document has +no opinion on what an operation costs or who may see it. Credentials are never +imported. See [docs/openapi-import.md](docs/openapi-import.md) for the exact +supported subset. + See [docs/configuration.md](docs/configuration.md). ## Protocol support @@ -404,12 +419,12 @@ See [CONTRIBUTING.md](CONTRIBUTING.md). ## Roadmap **Now (v1.1.0)** - MCP, x402 v2, settlement on the local chain, Base Sepolia -and Base mainnet, receipts, doctor, deterministic demo, and an experimental -A2A v1.0.0 adapter. +and Base mainnet, receipts, doctor, deterministic demo, an experimental +A2A v1.0.0 adapter, and experimental OpenAPI import. -**Next** - OpenAPI import · a stronger conformance suite · a `doctor` GitHub -Action · UCP · MPP · ACP · AP2 · Shopify and WooCommerce examples · -PostgreSQL · richer observability. +**Next** - a stronger conformance suite · a `doctor` GitHub Action · UCP · +MPP · ACP · AP2 · Shopify and WooCommerce examples · PostgreSQL · richer +observability · multi-file and remote OpenAPI sources. New protocols land only after the adapter model survives real use. Scope discipline is a release requirement, not a mood. @@ -422,6 +437,7 @@ discipline is a release requirement, not a mood. | [Payment flow](docs/payment-flow.md) | the paid round trip, and every way it fails | | [Protocols](docs/protocols.md) | exactly what is and is not supported | | [Configuration](docs/configuration.md) | `config.yaml` reference | +| [OpenAPI import](docs/openapi-import.md) | generate resources from an existing API | | [Security model](docs/security.md) | trust boundaries, and what we do not defend | | [Contracts](docs/contracts.md) | the frozen cross-package contract | | [Adapter guide](docs/contributing-adapters.md) | add a protocol or a payment rail | diff --git a/docs/architecture.md b/docs/architecture.md index d3cb7fd..cc522e0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -3,7 +3,7 @@ ## The problem A merchant already has an HTTP API. AI agents are learning to discover, invoke -and pay for capabilities through a growing set of protocols — MCP, x402, and +and pay for capabilities through a growing set of protocols - MCP, x402, and several more arriving. Implementing each one inside every merchant backend does not scale, and handing the money to a proprietary middleman defeats the point. @@ -41,7 +41,24 @@ Three properties are load-bearing: - **Non-custodial.** The gateway orchestrates a payment protocol; it never holds funds or keys. See. - **Configuration, not rewriting.** A merchant exposes an existing endpoint by - describing it in `config.yaml`. + describing it in `config.yaml`. If they already have an OpenAPI description, + `agent-commerce import openapi` writes that configuration for them - an + ingress tool, not a second runtime: + + ```text + OpenAPI -> importer -> resource definitions -> canonical model -> pipeline + ``` + + and never: + + ```text + OpenAPI -> a separate runtime executor + ``` + + The importer terminates at the config boundary. No OpenAPI type exists in + `src/core`, nothing reads the document after import, and an imported resource + is indistinguishable at runtime from one typed by hand. See + [openapi-import.md](openapi-import.md). ## Canonical model before protocol adapters @@ -64,12 +81,12 @@ boundaries. Provider-native payment challenges ride through the core as opaque passes them through and never inspects them. Why this matters: adding ACP, AP2, A2A or a second payment rail becomes one new -adapter rather than a core rewrite — and semantics from one protocol cannot leak +adapter rather than a core rewrite - and semantics from one protocol cannot leak into another. See. ## The execution pipeline -Every adapter converges here. Nothing bypasses it — that is what makes payment +Every adapter converges here. Nothing bypasses it - that is what makes payment enforcement a property of the system rather than of each adapter. ```text @@ -103,7 +120,7 @@ funds move. Every flow has one `requestId`, generated by the protocol adapter and carried through every log line, event, payment attempt and receipt. That single id is -what makes a live demo — and a post-incident investigation — legible. +what makes a live demo - and a post-incident investigation - legible. Event sequence for a successful paid request: @@ -120,7 +137,7 @@ must never become a payment failure, and vice versa. ## Receipts and audit -SQLite, three tables — `receipts`, `events`, `payment_attempts` — behind a thin +SQLite, three tables - `receipts`, `events`, `payment_attempts` - behind a thin repository. `payment_attempts.replay_key` carries a `UNIQUE` constraint, which is what makes the replay defence atomic rather than advisory. No secrets and no raw payment proofs are persisted. @@ -142,6 +159,7 @@ demo buyer agent is a deterministic program, and that is the path CI runs. | MCP adapter | `src/protocols/mcp` | | x402 provider + local/remote facilitator | `src/payments/x402` | | SQLite receipts/events/attempts | `src/storage/receipts` | -| CLI (`init`, `validate`, `doctor`, `demo`) | `src/cli` | +| OpenAPI import (config ingress only) | `src/openapi` | +| CLI (`init`, `import`, `validate`, `doctor`, `demo`) | `src/cli` | | demo merchant API / buyer / dashboard | `demo/*` | | MockUSDC + local chain scripts | `contracts/`, `scripts/chain/` | diff --git a/docs/configuration.md b/docs/configuration.md index 8dbdee9..3be83a6 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -3,7 +3,7 @@ One file, `config.yaml`, validated before the server starts. Start from [`config.example.yaml`](../config.example.yaml) or generate one: -`config.yaml` is yours — it is git-ignored, and `agent-commerce init` writes it +`config.yaml` is yours - it is git-ignored, and `agent-commerce init` writes it by default. The demo stack in this repository runs its own [`config-demo.yaml`](../config-demo.yaml) instead, so the two never collide. @@ -66,11 +66,75 @@ the HTTP path segment, so it must be unique and a legal tool name. `url` supports `{param}` templating from validated input; values are URL-encoded. Remaining input becomes query string for `GET`/`DELETE` and a JSON -body otherwise. +body otherwise - unless `backend.inputBindings` says otherwise, below. **Backend URLs are administrator configuration.** They are never taken from request input, and redirects are not followed. See [security.md](security.md). +### `backend.inputBindings` + +Optional. Names the top-level input properties carrying each part of the +backend request: + +```yaml + input: + type: object + properties: + path: + type: object + properties: + userId: { type: string } + required: [userId] + query: + type: object + properties: + notify: { type: boolean } + body: + type: object + properties: + productId: { type: string } + required: [path] + backend: + type: http + method: POST + url: ${MERCHANT_API_BASE_URL}/users/{userId}/orders + inputBindings: + path: path + query: query + body: body +``` + +**Absent is the legacy behaviour, unchanged**: `{param}` values come from +top-level input, and everything left over becomes the query string (`GET`, +`DELETE`) or the entire JSON body (`POST`, `PUT`, `PATCH`). Every existing +configuration keeps working exactly as before. + +**Present is explicit mode.** Each group is sourced independently, so one +operation can carry path parameters, query parameters *and* a JSON body at +once - which the leftover rule cannot express, because on a body-capable +method everything not consumed by the URL template becomes the body. Top-level +input that no binding names is **not forwarded to the backend at all**. + +The names are yours; `path` / `query` / `body` is only the convention the +OpenAPI importer generates. Config load rejects, before the gateway starts: + +- a binding to a property the input schema does not declare (schemas are + closed, so it could never be supplied); +- `path` or `query` bound to something that is not an object schema; +- two locations bound to one property; +- a binding to `_payment`, which is reserved for payment proofs; +- a `body` binding on `GET` or `DELETE`, which send none; +- explicit bindings with no `path` binding while `url` is templated; +- a path group that is not in the input's `required`, or a `{param}` not + declared and required inside it - a caller who cannot supply a path + parameter makes every call unservable, and a paid one settles first. + +At request time the same rules run **before pricing**, so a malformed request +shape can never settle a payment and then fail to reach the backend. + +Generated by [`agent-commerce import openapi`](openapi-import.md); nothing +about the field is OpenAPI-specific. + ## Payments ```yaml @@ -122,12 +186,12 @@ facilitator: type: none # or: type: bearer, token: ${X402_FACILITATOR_TOKEN} ``` -`auth` may be omitted, which means the same as `type: none` — an explicit +`auth` may be omitted, which means the same as `type: none` - an explicit statement that this facilitator takes no credential, not a fallback. Only `none` and `bearer` exist; a facilitator requiring per-request signed credentials (a CDP JWT, for instance) is refused rather than sent nothing. -**What this deployment is** — `local`, `testnet` or `mainnet` — is derived +**What this deployment is** - `local`, `testnet` or `mainnet` - is derived from the pair, not from the network alone, because chain id 84532 is shared between the local dev chain and public Base Sepolia. It is reported by `doctor`, by `health()`, and at `/.well-known/agent-commerce`. @@ -152,7 +216,7 @@ HTTP is allowed only to a local/private host, and a development `payTo` is refused on testnet too. `agent-commerce validate` and `agent-commerce doctor` run exactly the checks -the gateway runs at startup — the same function, not a second copy of the +the gateway runs at startup - the same function, not a second copy of the rules. `facilitator.auth` has three types: `none`, `bearer` (a static token, needs @@ -163,7 +227,7 @@ refused at config load rather than sent nothing. ### `assetName` is the EIP-712 domain, not the symbol The two USDC deployments disagree. Base Sepolia's reports `"USDC"`; Base -mainnet's reports `"USD Coin"` — it predates the rename. The buyer signs that +mainnet's reports `"USD Coin"` - it predates the rename. The buyer signs that string into their EIP-712 domain and the scheme checks it, so naming the obvious-looking value gets every payment refused `invalid_exact_evm_token_name_mismatch` *after* they have signed. Both values @@ -188,7 +252,7 @@ resource schema uses one. That is not merely "weaker validation". If a resource's backend URL contains a `{param}` template, you have **no configuration-level way to reject an empty -string** for it — `minLength: 1` will not be enforced. The gateway rejects +string** for it - `minLength: 1` will not be enforced. The gateway rejects empty, `.` and `..` path parameters itself, before any payment is taken, but anything else you intended `pattern` to exclude will reach your backend. diff --git a/docs/openapi-import.md b/docs/openapi-import.md new file mode 100644 index 0000000..29e1bfe --- /dev/null +++ b/docs/openapi-import.md @@ -0,0 +1,226 @@ +# OpenAPI import + +`agent-commerce import openapi` reads an OpenAPI description and writes Agent +Commerce resource drafts. The output is configuration. You review it, fill in +the parts OpenAPI cannot tell us, and merge it into `config.yaml`; from there +an imported resource is handled exactly like one you typed by hand. + +```text +OpenAPI document -> importer -> resource drafts -> config.yaml -> canonical model +``` + +The importer stops at that boundary. There is no second executor, `src/core` +contains no OpenAPI type, and once the drafts are written nothing reads the +document again. + +The feature is experimental. It handles the shapes most REST APIs are built +from and refuses the rest instead of approximating them, so check the +[support matrix](#support-matrix) before assuming a document will import whole. + +## Workflow + +```bash +agent-commerce import openapi openapi.yaml +``` + +Then work through the generated file: + +1. read the resources it produced, which describe the API shape and nothing else; +2. decide pricing, free or a fixed amount and currency; +3. decide exposure: `http`, `mcp`, `a2a`; +4. add backend authentication under `backend.headers`, using `${ENV_VAR}` + placeholders rather than a literal credential; +5. merge the resources into `config.yaml` under `resources:`; +6. run `agent-commerce validate`; +7. run `agent-commerce doctor`. + +The generated file is a `resources:` fragment with review comments above each +entry. Unless you passed `--free` and `--expose`, it has no `pricing` or +`expose` keys at all and will not load until steps 2 and 3 have happened. An +OpenAPI document says nothing about what an operation costs or who should see +it, and a wrong guess either gives a merchant's endpoint away or publishes it +to an agent network. + +### Options + +| Option | Effect | +| --- | --- | +| `--output ` | default `.agent-commerce.yaml` in the working directory | +| `--force` | overwrite an existing output file; without it, an existing file stops the run | +| `--base-url ` | backend base URL, overriding every `servers` entry. Must be absolute `http(s)` with no query or fragment | +| `--operation ` | import only this operation (repeatable; matches `operationId` or the generated id). One that matches nothing fails the run | +| `--tag ` | import only operations carrying this tag (repeatable, OR-ed) | +| `--free` | write `pricing: { type: free }` | +| `--expose ` | write `expose:`, comma-separated `http,mcp,a2a` | +| `--strict` | any warning fails the run | +| `--json` | machine-readable summary instead of the report | + +The command exits `0` on success, warnings included. It exits `1` on a fatal +load, import or write error, on an `--operation` that matched nothing, when no +supported operation was imported, and on any warning under `--strict`. A failed +run writes no file, so the next command cannot pick up half an import. + +## What a draft looks like + +`POST /users/{userId}/orders?notify=true` with a JSON body becomes: + +```yaml +resources: + # REVIEW: pricing and exposure are not inferred from OpenAPI. Add e.g. + # pricing: { type: free } + # expose: [http] + createOrder: + name: Create an order + input: + type: object + properties: + path: + type: object + properties: + userId: { type: string } + required: [userId] + additionalProperties: false + query: + type: object + properties: + notify: { type: boolean } + required: [notify] + additionalProperties: false + body: + type: object + properties: + productId: { type: string } + quantity: { type: integer } + required: [productId] + additionalProperties: false + required: [path, query, body] + additionalProperties: false + backend: + type: http + method: POST + url: https://api.example.com/users/{userId}/orders + inputBindings: + path: path + query: query + body: body +``` + +Each location gets its own namespace, so a `?id=` and a `{id}` in the same +operation cannot collide, and `backend.inputBindings` tells the executor where +to read each part of the request from. See +[configuration.md](configuration.md#backendinputbindings). + +Resource ids are stable across runs. An id comes from `operationId`, normalised +to the allowed character set, or from `_` when the operation has +none. Counters and random suffixes are never used, because an agent discovers +an id and then hard-codes it. If two operations resolve to the same id the +import fails and names both, rather than quietly renaming one of them. + +## Support matrix + +| Feature | Status | +| --- | --- | +| OpenAPI 3.0 | supported | +| OpenAPI 3.1 | supported | +| OpenAPI 3.2 | supported | +| Swagger 2.0 | unsupported, convert first | +| YAML source | supported | +| JSON source | supported | +| internal `$ref` | supported | +| external `$ref` (file or URL) | unsupported, refused | +| remote URL source | unsupported | +| GET, POST, PUT, PATCH, DELETE | supported | +| HEAD, OPTIONS, TRACE, QUERY, other | skipped with a warning | +| path parameters | supported subset: primitive, default (`simple`) style | +| query parameters | supported subset: primitive, default (`form`) style | +| `deepObject`, `spaceDelimited`, `pipeDelimited` | unsupported | +| object/array parameters, parameter `content` form | unsupported | +| `application/json` body | supported | +| `application/*+json` body | supported, with a static `Content-Type` header | +| multipart, form-urlencoded, binary, streaming bodies | unsupported | +| body on GET/DELETE | omitted with a warning; the gateway sends none | +| dynamic header parameters | unsupported | +| cookie parameters | unsupported | +| security credential import | unsupported, deliberately | +| output schema | one deterministic 2xx JSON response | + +When an operation requires something from that list that the gateway cannot +represent, the whole operation is skipped. When the same thing is optional, it +is left out and you get a warning. Approximating it would produce a resource +that looks importable, takes payment, and then calls the backend wrongly, which +on a paid resource means a buyer pays for a request the merchant never receives +correctly. + +### Schemas + +Schemas are converted to the subset the gateway enforces: `type` (with 3.0 +`nullable` folded into a union type), `properties`, `required`, +`additionalProperties`, `enum` and `items`, plus the descriptive `title`, +`description`, `default` and `example`/`examples`. + +Anything else is dropped and listed in the import warnings. That includes +`pattern`, `format`, `minimum`, `maxLength` and tuple-form `items`. Copying +them through would advertise validation to agents that no code performs; see +[configuration.md](configuration.md#unsupported-json-schema-keywords-have-a-cost) +for what that costs you. + +An `allOf` of compatible object schemas is merged. A branch conflict, +`oneOf`, `anyOf`, `not`, `discriminator` or a reference cycle makes a request +schema unsupported and skips the operation. In an output schema the same cases +only omit the schema, since output is descriptive and carries no request +safety. + +## Security model + +The importer reads a local document that the operator supplied. There is no +remote source option, and it makes no network requests of its own. Every `$ref` +is checked before the validator sees the document, and an external one +(`https://example.com/types.yaml`, `./types.yaml`, `file:///tmp/types.yaml`) is +refused by name. Internal references resolve lazily with cycle detection, so a +recursive schema produces a diagnostic instead of expanding until the process +runs out of memory. + +Backend hosts come from the document's `servers` or from `--base-url`, both +operator input at import time. A relative or unresolvable server URL is refused +rather than guessed from the filename, and agent input never contributes to a +backend host. See [security.md](security.md#ssrf). + +No credential is ever imported. An operation that declares OpenAPI security +gets a warning and a review comment asking you to add +`backend.headers: { Authorization: Bearer ${BACKEND_TOKEN} }` yourself. The +scheme name, the header name and any example value stay out of the generated +file, and a security scheme never becomes agent-supplied input. Header +parameters named `Accept`, `Content-Type` or `Authorization` are ignored, as +the OpenAPI specification requires; they are transport and operator concerns. + +Descriptions, examples and vendor `x-` extensions are data. They are either +serialised into YAML or dropped, nothing in a document is executed, and no `x-` +extension can change pricing, payments, the backend URL or protocol exposure. +The output file is never overwritten without `--force`, and `config.yaml` is +never modified. + +## Limitations worth knowing before you start + +Multi-file descriptions do not work. Bundle them first with `redocly bundle` or +`swagger-cli bundle`, then import the single file. + +Only one success response is used: `200`, then `201`, `202`, then the remaining +explicit 2xx codes in ascending order. Status-dependent unions are not +modelled, and when several responses carry a JSON body the import warns and +names the one it took. + +The only pricing the CLI writes is `--free`. Per-operation prices are a manual +edit. + +Re-importing overwrites, it never merges. The importer does not edit an +existing file, so run it into a new path and diff the two. + +## Where the code lives + +Everything lives under `src/openapi/`: `load.ts` reads and validates the +document and refuses external refs, `refs.ts` resolves internal references, +`discover.ts` finds operations and works out ids and server URLs, `schema.ts` +converts schemas, `request.ts` turns parameters and the request body into an +input schema plus bindings, and `draft.ts` builds the resource drafts and +renders the YAML. The command itself is +`src/cli/commands/import-openapi.ts`. diff --git a/docs/security.md b/docs/security.md index 3608e7a..f156cd8 100644 --- a/docs/security.md +++ b/docs/security.md @@ -19,7 +19,7 @@ deliberately do not defend. ``` Everything from an agent is untrusted and validated. Configuration is trusted -input supplied by whoever runs the gateway — which is why backend URLs may only +input supplied by whoever runs the gateway - which is why backend URLs may only come from configuration. ## Secret handling @@ -38,19 +38,19 @@ receipt store. **The logger's depth limit is real.** `fast-redact` wildcards match a single level, so `REDACT_PATHS` covers `privateKey` and `wallet.privateKey` but not `a.b.privateKey`. Nothing leaks today because every call site funnels caught -errors through `describeError`, which extracts only `{message, name}` — but +errors through `describeError`, which extracts only `{message, name}` - but that is a property of the call sites, not of the redaction config. Do not log a raw object that may carry a secret at depth ≥ 2. The **receipt store's** redaction (`src/storage/receipts/redact.ts`) has no such limit: it is a recursive key-pattern strip at every depth. Both have tests. Resolved `${VAR}` values are never printed, even -in configuration error messages — errors name the *variable*, not the value. +in configuration error messages - errors name the *variable*, not the value. ## SSRF The gateway makes outbound HTTP calls to URLs it was configured with: - backend URLs are **administrator-controlled configuration only**; -- dynamic, agent- or user-controlled backend URLs are **forbidden** — no code +- dynamic, agent- or user-controlled backend URLs are **forbidden** - no code path constructs a backend URL from request input beyond `{param}` substitution into a configured template, with each value URL-encoded. A parameter may only appear once the authority is complete: a template whose `{param}` reaches the @@ -64,24 +64,31 @@ The gateway makes outbound HTTP calls to URLs it was configured with: Path parameters are additionally checked for dot segments: `.` and `..` are rejected as `INPUT_INVALID` rather than URL-encoded, because `encodeURIComponent` does not escape `.` and the WHATWG URL parser then -normalises `..` away — which would let a caller *remove* path segments and reach +normalises `..` away - which would let a caller *remove* path segments and reach a parent endpoint the operator never exposed. After substitution the constructed path is asserted to still begin with the template's literal prefix. Caller input can never override a query parameter the operator baked into -`backend.url`. A collision is rejected, not silently applied — otherwise an +`backend.url`. A collision is rejected, not silently applied - otherwise an input key named after an embedded `?apikey=…` would replace it. Not implemented: an IP/CIDR allowlist or a private-address blocklist. If you configure `http://169.254.169.254/…`, the gateway will call it. Treat configuration as privileged. +A backend URL produced by [`agent-commerce import openapi`](openapi-import.md) +is configuration too: it comes from the document's `servers` or from +`--base-url`, both supplied by the operator at import time, and it lands in a +file a human reviews before it is merged. A relative or unresolvable server URL +is refused rather than guessed at. The importer makes no network requests of +its own. + ### Backend response relay On a non-2xx backend response, the gateway states the **status code** to the caller (ours to say) but does not forward the backend's **response body**. A merchant backend in verbose/dev-error mode routinely emits stack traces, -internal hostnames or SQL fragments — a free, often-unauthenticated resource +internal hostnames or SQL fragments - a free, often-unauthenticated resource call is not a safe place to relay that. The body is truncated (512 chars) and logged at `debug` for the operator only; it never reaches a client-visible field. A merchant that wants pass-through is a per-resource opt-in, post-alpha. @@ -92,7 +99,7 @@ Validated at the boundary, before anything else happens: | Input | Check | | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| resource input | JSON Schema from the resource definition, closed by default at every level: an object schema — root, nested under `properties`, nested under `items`, or nested under an `additionalProperties` subschema — that omits `additionalProperties` gets `additionalProperties: false` stamped on recursively at config load, not just at the root. That enumeration was written from the stamper rather than from the validator and drifted three times; it now matches what `compileJsonSchema` actually recurses into; an operator who sets it explicitly (including explicitly to `true`) is respected at whichever level they set it. A resource that declares no `input:` at all gets an empty closed schema, not an always-valid one — declaring nothing means accepting nothing. Unknown properties, including prototype-named keys (`__proto__`, `constructor`, …), are matched by own-property lookup only. | +| resource input | JSON Schema from the resource definition, closed by default at every level: an object schema - root, nested under `properties`, nested under `items`, or nested under an `additionalProperties` subschema - that omits `additionalProperties` gets `additionalProperties: false` stamped on recursively at config load, not just at the root. That enumeration was written from the stamper rather than from the validator and drifted three times; it now matches what `compileJsonSchema` actually recurses into; an operator who sets it explicitly (including explicitly to `true`) is respected at whichever level they set it. A resource that declares no `input:` at all gets an empty closed schema, not an always-valid one - declaring nothing means accepting nothing. Unknown properties, including prototype-named keys (`__proto__`, `constructor`, …), are matched by own-property lookup only. | | path parameters | URL-encoded on substitution | | body size | capped at 256 KB, one number for both surfaces, enforced in two different places: Fastify's `bodyLimit` runs inside a body parser on the HTTP routes; `/mcp` deliberately installs a no-op parser so the MCP transport can read the raw stream, so the mount enforces its own byte count instead. A cap that only protects one of two entry points, or two caps that can silently drift apart, is how `/mcp` ended up with no cap at all in the first place. | | content type | JSON enforced on the invoke routes. **Not** on `/mcp`, where a wildcard no-op parser hands the raw stream to the MCP SDK and the SDK does its own enforcement. | @@ -106,7 +113,7 @@ validation, so it can never collide with a resource's own properties. Covered in detail in [payment-flow.md](payment-flow.md). The invariants: -1. Paid resources **fail closed** — thirteen distinct failure conditions, each +1. Paid resources **fail closed** - thirteen distinct failure conditions, each with a test, none of which delivers the resource. 2. `verify` has no fund-moving side effects; only `settle` does. 3. Replay is defended twice: on-chain via EIP-3009 `authorizationState`, and in @@ -127,12 +134,12 @@ The surface splits by audience, and the split is enforced, not advisory: | -------------------------------------------------------------- | ------------- | -------------------------------------------------------- | | `POST /api/resources/:id/invoke` | agents | payment, not authentication | | `/mcp` | agents | payment, not authentication | -| `GET /api/resources`, `/health`, `/.well-known/agent-commerce` | anyone | none — public by design | +| `GET /api/resources`, `/health`, `/.well-known/agent-commerce` | anyone | none - public by design | | `GET /ready` | operators | none, but detail is a fixed vocabulary, never raw errors | | `GET /api/receipts`, `/api/events`, `/api/events/stream` | **operators** | `server.adminToken`, compared in constant time | The operator routes carry the merchant's commerce ledger. With no -`server.adminToken` configured they return **404**, not open data — a missing +`server.adminToken` configured they return **404**, not open data - a missing control must not read as an absent restriction. Browser access uses `server.allowedOrigins`, an explicit allowlist defaulting to @@ -142,7 +149,7 @@ which is the same thing as having no policy. Agent traffic gets no CORS headers at all, because it is not browser traffic. `Origin` and `Host` are validated in one place, the gateway's `onRequest` hook, -so the MCP mount is covered by the same rule as the HTTP routes — including +so the MCP mount is covered by the same rule as the HTTP routes - including against DNS rebinding at a hostname resolving to `127.0.0.1`. Every published port in `docker-compose.yml` binds `127.0.0.1`. Port 8545 in @@ -156,7 +163,7 @@ The gateway does not forward the merchant backend's own error body to callers. A backend in verbose or development error mode routinely emits stack traces, internal hostnames and SQL fragments; relaying them would make the gateway a pass-through for someone else's internals to whoever called a free resource. -The backend's **status code** is returned — that is ours to state and useful — +The backend's **status code** is returned - that is ours to state and useful - and the body is logged server-side at debug level only. The same rule governs health and readiness detail: a fixed vocabulary on the @@ -169,13 +176,13 @@ Not a focus of this release, but more is in place than this section used to list. What exists: - request body-size cap, enforced inside the body parsers -- a **1 MB cap on the merchant backend's *response*** — `AbortSignal.timeout` +- a **1 MB cap on the merchant backend's *response*** - `AbortSignal.timeout` bounds a backend call by time, not by bytes - explicit backend timeouts on every outbound call - a bounded list limit on every receipts/events query, applied in the store - a cap on concurrent SSE subscribers - on `/mcp`: a counting semaphore bounding concurrent tool calls (8) plus a - bounded queue (64) — over that, `GATEWAY_BUSY` rather than unbounded growth + bounded queue (64) - over that, `GATEWAY_BUSY` rather than unbounded growth - readiness memoisation and single-flight, so `/ready` polling cannot amplify into one upstream RPC call per request - `X-Request-Id` accepted only as `[A-Za-z0-9._:-]{1,64}`, so a caller cannot @@ -198,12 +205,12 @@ What a consumer actually installs, audited against the published tarball: | ------------------------------------------------------------------- | ---------------------- | | the package alone | **0 vulnerabilities** | | plus `@modelcontextprotocol/sdk`, `@x402/core`, `@x402/evm`, `viem` | **0 vulnerabilities** | -| plus `@coinbase/x402` (only for `auth.type: cdp`) | 2 — 1 high, 1 moderate | +| plus `@coinbase/x402` (only for `auth.type: cdp`) | 2 - 1 high, 1 moderate | The whole delta is CDP: `@coinbase/x402` → `@coinbase/cdp-sdk` → `axios`, which carries a set of high-severity advisories, plus a Solana client tree this project has no use for. It is an optional peer, imported dynamically only when -that auth type is configured, so nobody else pays for it — and `auth.type: +that auth type is configured, so nobody else pays for it - and `auth.type: bearer` covers any facilitator with a static token and installs nothing. This is stated rather than buried because the affected path is the one handling real money. @@ -218,7 +225,7 @@ those addresses, and never reuse them anywhere else. Two independent checks refuse them where it would matter: a dev *key* may not sign against an RPC that does not look local or private, and a dev *address* may not be the settlement destination on any non-local deployment. The second -is the consequential one — a wrong key merely fails to sign, a wrong `payTo` +is the consequential one - a wrong key merely fails to sign, a wrong `payTo` succeeds and gives the money away. ## Mainnet @@ -231,14 +238,14 @@ including the EIP-712 domain name it reports. All of it is checked at config load, so the gateway does not start otherwise and `agent-commerce validate` reports it without starting anything. -A facilitator cannot redirect your money — an EIP-3009 authorisation names its +A facilitator cannot redirect your money - an EIP-3009 authorisation names its recipient, its amount and its chain, so it can broadcast exactly that transfer or nothing. What it can do is see every authorisation you handle, and stop answering. That is why an unauthenticated one is a separate, explicit acknowledgement rather than a warning. The in-process facilitator is never allowed on a mainnet. To be precise about -why: it is not a custody problem — the facilitator signer never holds buyer or +why: it is not a custody problem - the facilitator signer never holds buyer or merchant funds, it pays gas and broadcasts `transferWithAuthorization`, and the money moves buyer to merchant directly on-chain. It is a *funded key inside the resource server*, so compromising that process means draining the gas wallet @@ -261,7 +268,7 @@ rejection outcomes assert that balances did not move. | amount manipulation (below the price) | `wrong_amount` before settlement | `tests/e2e/payment` | | replay, sequentially | refused, no second transfer | `tests/e2e/payment`, mainnet suite | | **duplicate concurrent request** | settles once, other gets `PAYMENT_REPLAYED` | `tests/integration/adversarial-payment.test.ts` | -| **replay after a gateway restart** | still refused — the reservation is in SQLite | same | +| **replay after a gateway restart** | still refused - the reservation is in SQLite | same | | expired authorisation (`validBefore` in the past) | refused before settlement | `tests/e2e/payment` | | not-yet-valid authorisation (`validAfter` in the future) | refused before settlement | `tests/e2e/payment` | | a `{param}` in the host position of `backend.url` | refused at config load | `tests/unit/config/schema.test.ts` | @@ -275,15 +282,57 @@ rejection outcomes assert that balances did not move. | receipt-store failure | `STORAGE_ERROR`, never mislabelled `PAYMENT_REPLAYED` | `tests/unit/storage-receipts` | | a local reader racing the ledger's creation | database and sidecars are 0600 from the moment SQLite opens them | `tests/unit/storage-receipts/permissions.test.ts` | | RPC unreachable during verify | `PAYMENT_PROVIDER_UNAVAILABLE`, not "bad signature" | `tests/unit/payments-x402` | +| **an external `$ref` in an imported document** | refused; zero outbound requests | `tests/unit/openapi/load.test.ts` | +| **an imported path value naming another host** | percent-encoded into one segment of the configured origin | `tests/integration/openapi-import.test.ts` | +| **an imported query group colliding with a pinned backend query** | `INPUT_INVALID` before payment; nothing settled | same | +| **an imported operation with an unsupported required parameter** | never becomes a resource at all | same | Two of those exist because writing them found a bug. The SDK's `exact`/EVM scheme reports an unreachable node as `invalid_exact_evm_signature`, and its -HTTP facilitator client throws a bare `Error` for a 401 or 5xx — both would +HTTP facilitator client throws a bare `Error` for a 401 or 5xx - both would have recorded a failure of ours as the payer's fault, and burned an authorisation nothing had checked. The provider now treats *any* throw out of a facilitator call as "no verdict obtained", because a verdict arrives as a returned value. +## OpenAPI import + +The importer reads a **local, operator-supplied** document and writes a file +for review. It is not on the request path, and after import nothing reads the +document again. + +- **No network, no filesystem walk.** There is no remote source option, and + every `$ref` is checked before the validator sees the document: an external + reference (`https://…`, `./types.yaml`, `file:///…`) is refused by name. A + document that names one cannot cause a single outbound request. Asserted with + a stubbed `fetch` in `tests/unit/openapi/load.test.ts` and again end to end + in `tests/integration/openapi-import.test.ts`. +- **Bounded work.** A source over 10 MiB is refused before parsing. Internal + references are resolved lazily with cycle detection and a depth bound, so a + recursive schema is a diagnostic rather than an out-of-memory kill. +- **No credential is ever imported.** An operation declaring OpenAPI security + produces a warning and a review comment pointing at + `backend.headers: { Authorization: Bearer ${BACKEND_TOKEN} }`; the scheme + name, header name and any example value stay out of the generated file. A + security scheme never becomes agent-supplied input, and `Accept`, + `Content-Type` and `Authorization` header parameters are ignored per the + specification. +- **The document is data.** Descriptions, examples and vendor `x-` extensions + are serialised into YAML or dropped. Nothing in a document is executed, and + no `x-` extension can alter pricing, payments, the backend URL or protocol + exposure - the generator reads only the fields it knows. +- **Nothing is overwritten.** An existing output file stops the run unless + `--force` is passed, a failed run writes nothing at all, and the write is a + temp sibling plus rename so a crash cannot leave a partial file. +- **Commerce policy is never inferred.** Without `--free` / `--expose` the + generated fragment has no `pricing` or `expose` and will not load, so no + operation becomes purchasable or agent-visible without a human deciding so. +- **Imported resources are not privileged.** They are ordinary + `CommerceResource` entries: same config validation, same execution pipeline, + same pre-payment request-shape checks. `tests/integration/openapi-import.test.ts` + drives one over HTTP, MCP and A2A and asserts all three produce the identical + merchant request. + ## Threats we are not addressing Buyer identity and screening · fraud and disputes · refunds and chargebacks ·