Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 131 additions & 0 deletions apps/scan/src/lib/discovery/probe.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { describe, expect, it } from 'vitest';

import type {
CheckEndpointResult,
EndpointMethodAdvisory,
} from '@agentcash/discovery';

import { pickX402Advisory } from './probe';

const X402_PAYMENT_OPTS: EndpointMethodAdvisory['paymentOptions'] = [
{
protocol: 'x402',
version: 2,
network: 'eip155:8453',
asset: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
amount: '10000',
scheme: 'exact',
payTo: '0x7484b0bca25d2ee56e9b0535572d4cf44a047d98',
maxTimeoutSeconds: 300,
},
];

function makeAdvisory(
method: EndpointMethodAdvisory['method'],
inputSchema?: Record<string, unknown>
): EndpointMethodAdvisory {
return {
source: 'probe',
method,
authMode: 'paid',
paymentOptions: X402_PAYMENT_OPTS,
...(inputSchema ? { inputSchema } : {}),
};
}

function makeResult(advisories: EndpointMethodAdvisory[]): CheckEndpointResult {
return {
found: true,
origin: 'https://example.test',
path: '/paid/endpoint',
advisories,
};
}

describe('pickX402Advisory', () => {
it('returns undefined when discovery did not find the endpoint', () => {
expect(
pickX402Advisory({
found: false,
origin: 'https://example.test',
path: '/paid/endpoint',
cause: 'not_found',
})
).toBeUndefined();
});

it('returns undefined when no advisories carry x402 payment options', () => {
const result: CheckEndpointResult = makeResult([
{
source: 'probe',
method: 'POST',
authMode: 'paid',
paymentOptions: [],
},
]);
expect(pickX402Advisory(result)).toBeUndefined();
});

it('prefers POST over GET when no method is declared by the spec', () => {
const get = makeAdvisory('GET', { type: 'object' });
const post = makeAdvisory('POST', { type: 'string' });
const picked = pickX402Advisory(makeResult([get, post]));
expect(picked?.method).toBe('POST');
expect(picked?.inputSchema).toEqual({ type: 'string' });
});

// Regression: GoldBean API (https://goldbean-api.xyz) issue #923.
//
// The OpenAPI spec declares only GET on /paid/* endpoints, but the
// payment middleware returns 402 for every HTTP method. The probe
// therefore yields advisories for both GET (with `inputSchema`
// populated from the spec's query parameters) and POST (with no
// `inputSchema` because no POST is declared).
//
// Before the fix, pickX402Advisory picked the POST advisory by
// method preference, rewrote `.method` to "GET" from the declared
// OpenAPI method, and registered the endpoint without a schema —
// surfacing "Missing input schema" on every GET endpoint.
//
// Expected: when `preferredMethod` matches an advisory directly,
// return that advisory unchanged so the per-method `inputSchema`
// survives.
it('prefers the advisory whose method matches `preferredMethod`', () => {
const get = makeAdvisory('GET', {
parameters: [{ name: 'address', in: 'query' }],
});
const post = makeAdvisory('POST'); // no inputSchema — only declared on GET
const picked = pickX402Advisory(makeResult([get, post]), 'GET');
expect(picked?.method).toBe('GET');
expect(picked?.inputSchema).toEqual({
parameters: [{ name: 'address', in: 'query' }],
});
});

it('keeps the case-insensitive match for `preferredMethod`', () => {
const get = makeAdvisory('GET', { type: 'object' });
const post = makeAdvisory('POST');
const picked = pickX402Advisory(makeResult([get, post]), 'get');
expect(picked?.method).toBe('GET');
expect(picked?.inputSchema).toEqual({ type: 'object' });
});

// When the OpenAPI spec declares a method that the probe did not
// see (e.g. middleware order means only one method actually 402s),
// fall back to the most-preferred candidate and rewrite `.method` so
// the registered resource reflects the declared method. The schema
// is whatever the probe found — better than dropping the endpoint.
it('falls back to method-preference and rewrites .method when no advisory matches `preferredMethod`', () => {
const patch = makeAdvisory('PATCH');
const put = makeAdvisory('PUT');
const picked = pickX402Advisory(makeResult([patch, put]), 'POST');
// PUT > PATCH in METHOD_PREFERENCE, so PUT wins…
expect(picked?.method).toBe('POST'); // …but `.method` is rewritten to the declared method.
});

it('returns the only candidate when its method does not match `preferredMethod` and is outside METHOD_PREFERENCE order', () => {
const options = makeAdvisory('OPTIONS' as EndpointMethodAdvisory['method']);
const picked = pickX402Advisory(makeResult([options]), 'GET');
expect(picked?.method).toBe('GET'); // rewritten to declared method
});
});
28 changes: 21 additions & 7 deletions apps/scan/src/lib/discovery/probe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ export type ProbeX402Result =

const METHOD_PREFERENCE = ['POST', 'GET', 'PUT', 'PATCH', 'DELETE'] as const;

function pickX402Advisory(
export function pickX402Advisory(
result: CheckEndpointResult,
preferredMethod?: string
): EndpointMethodAdvisory | undefined {
Expand All @@ -86,18 +86,32 @@ function pickX402Advisory(
const candidates = result.advisories.filter(a =>
a.paymentOptions?.some(p => p.protocol === 'x402')
);
if (candidates.length === 0) return undefined;

// If the OpenAPI spec declares a specific method, prefer the advisory for
// that method when one exists. Some APIs return 402 for all HTTP methods
// because their payment middleware fires before method routing, so the
// probe lands on multiple methods even though only one is declared.
// Picking the matching advisory (rather than the most-preferred method and
// rewriting `.method`) preserves the per-method `inputSchema` extracted
// from the OpenAPI spec.
if (preferredMethod) {
const target = preferredMethod.toUpperCase();
const match = candidates.find(a => a.method === target);
if (match) return match;
}

// Prefer POST > GET > PUT > PATCH > DELETE; HEAD is excluded as it is
// auto-generated by OpenAPI for every GET endpoint.
const preferred =
METHOD_PREFERENCE.flatMap(m => candidates.filter(a => a.method === m))[0] ??
candidates[0];
if (!preferred) return undefined;
candidates[0]!;

// If the OpenAPI spec declares a specific method, trust it over the probe.
// Some APIs return 402 for all HTTP methods because their payment middleware
// fires before method routing, so the probe may land on PATCH/PUT even
// though the endpoint is declared as POST in the spec.
// OpenAPI declared a method but no probe advisory matches it. Fall back to
// the preferred-by-preference candidate, rewriting `.method` so the
// registered resource still reflects the declared method. (This is the
// "payment middleware before method routing" case where every method 402s
// but no per-method advisory carries the right schema.)
if (preferredMethod && preferred.method !== preferredMethod.toUpperCase()) {
return {
...preferred,
Expand Down
Loading