Skip to content
Merged
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
16 changes: 13 additions & 3 deletions apps/scan/src/lib/discovery/probe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import https from 'node:https';
import {
buildMinimalSampleFromInputSchema,
buildMinimalQueryParamsFromInputSchema,
hasPathParameters,
PROBE_TIMEOUT_MS,
} from './utils';

Expand Down Expand Up @@ -327,13 +328,14 @@ async function probeX402EndpointOnce(

return {
success: false,
error: probeErrorMessage(noBody, withBody),
error: probeErrorMessage(url, noBody, withBody),
skipped: !isUnreachable,
statusCode,
};
}

function probeErrorMessage(
url: string,
noBody: CheckEndpointResult,
withBody?: CheckEndpointResult
): string {
Expand All @@ -346,8 +348,16 @@ function probeErrorMessage(
timeout: 'Endpoint timed out',
} as const;
const base = causeMessages[result.cause];
// The library message often includes the status code (e.g. "got 400")
return result.message ?? base;
const message = result.message ?? base;

// Detect path parameters and append actionable guidance for merchants.
if (result.cause === 'not_found' && hasPathParameters(url)) {
return (
message +
'. This endpoint contains path parameters — ensure the x402 paywall runs before path parameter validation'
);
}
return message;
}
return 'No valid x402 response found';
}
4 changes: 1 addition & 3 deletions apps/scan/src/lib/discovery/register-endpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,8 @@ export async function registerEndpoint(
originMetadataFallback?: { title?: string; description?: string };
}
) {
const cleanUrl = url.replaceAll('{', '').replaceAll('}', '');

// 1. Probe the endpoint for a 402 response
const probeResult = await probeX402Endpoint(cleanUrl);
const probeResult = await probeX402Endpoint(url);

if (!probeResult.success) {
return {
Expand Down
202 changes: 202 additions & 0 deletions apps/scan/src/lib/discovery/utils/build-minimal-sample.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
import { describe, it, expect } from 'vitest';
import {
buildMinimalSampleFromInputSchema,
buildMinimalQueryParamsFromInputSchema,
} from './build-minimal-sample';

describe('buildMinimalSampleFromInputSchema', () => {
it('fills top-level required fields with type defaults', () => {
const schema = {
body: {
content: {
'application/json': {
schema: {
type: 'object',
required: ['name', 'count'],
properties: {
name: { type: 'string' },
count: { type: 'integer' },
optional: { type: 'string' },
},
},
},
},
},
};
expect(buildMinimalSampleFromInputSchema(schema)).toEqual({
name: 'test',
count: 0,
});
});

it('respects minimum constraint on integers', () => {
const schema = {
body: {
content: {
'application/json': {
schema: {
type: 'object',
required: ['duration'],
properties: {
duration: { type: 'integer', minimum: 1, maximum: 365 },
},
},
},
},
},
};
expect(buildMinimalSampleFromInputSchema(schema)).toEqual({ duration: 1 });
});

it('merges required fields from first anyOf branch', () => {
// Reproduces the store.nosub.club schema pattern:
// top-level required: ["duration"], anyOf: [{ required: ["contentBase64"] }, { required: ["contentText"] }]
const schema = {
body: {
content: {
'application/json': {
schema: {
type: 'object',
required: ['duration'],
anyOf: [
{ required: ['contentBase64'] },
{ required: ['contentText'] },
],
properties: {
duration: { type: 'integer', minimum: 1, maximum: 365 },
contentBase64: { type: 'string' },
contentText: { type: 'string' },
},
},
},
},
},
};
const result = buildMinimalSampleFromInputSchema(schema);
expect(result).toEqual({
duration: 1,
contentBase64: 'test',
});
});

it('merges required fields from first oneOf branch', () => {
const schema = {
body: {
content: {
'application/json': {
schema: {
type: 'object',
required: ['id'],
oneOf: [{ required: ['payload'] }],
properties: {
id: { type: 'string' },
payload: { type: 'string' },
},
},
},
},
},
};
const result = buildMinimalSampleFromInputSchema(schema);
expect(result).toEqual({ id: 'test', payload: 'test' });
});

it('handles anyOf with no required fields in branches', () => {
const schema = {
body: {
content: {
'application/json': {
schema: {
type: 'object',
required: ['name'],
anyOf: [{ type: 'object' }],
properties: {
name: { type: 'string' },
},
},
},
},
},
};
expect(buildMinimalSampleFromInputSchema(schema)).toEqual({ name: 'test' });
});

it('extracts schema from requestBody wrapper (discovery library format)', () => {
// This is the actual format returned by @agentcash/discovery's
// checkEndpointSchema — the schema lives under `requestBody`, not
// `body.content["application/json"].schema`.
const schema = {
requestBody: {
type: 'object',
required: ['duration'],
anyOf: [{ required: ['contentBase64'] }, { required: ['contentText'] }],
properties: {
duration: { type: 'integer', minimum: 1, maximum: 365 },
contentBase64: { type: 'string' },
contentText: { type: 'string' },
},
},
parameters: [
{
name: 'duration',
in: 'query',
required: false,
schema: { type: 'integer', minimum: 1 },
},
],
};
const result = buildMinimalSampleFromInputSchema(schema);
expect(result).toEqual({
duration: 1,
contentBase64: 'test',
});
});

it('returns undefined for non-object input', () => {
expect(buildMinimalSampleFromInputSchema(null)).toBeUndefined();
expect(buildMinimalSampleFromInputSchema('string')).toBeUndefined();
expect(buildMinimalSampleFromInputSchema([1, 2])).toBeUndefined();
});

it('returns undefined when no required fields exist', () => {
const schema = {
body: {
content: {
'application/json': {
schema: {
type: 'object',
properties: {
optional: { type: 'string' },
},
},
},
},
},
};
expect(buildMinimalSampleFromInputSchema(schema)).toBeUndefined();
});
});

describe('buildMinimalQueryParamsFromInputSchema', () => {
it('fills required query params with schema minimum', () => {
const schema = {
parameters: [
{
name: 'limit',
in: 'query',
required: true,
schema: { type: 'integer', minimum: 1 },
},
{
name: 'offset',
in: 'query',
required: false,
schema: { type: 'integer' },
},
],
};
expect(buildMinimalQueryParamsFromInputSchema(schema)).toEqual({
limit: '1',
});
});
});
32 changes: 28 additions & 4 deletions apps/scan/src/lib/discovery/utils/build-minimal-sample.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,10 @@ function sampleValue(schema: Record<string, unknown>, depth: number): unknown {
if (schema.format === 'uuid') return '00000000-0000-0000-0000-000000000000';
return 'test';
}
if (type === 'number' || type === 'integer') return 0;
if (type === 'number' || type === 'integer') {
if (typeof schema.minimum === 'number') return schema.minimum;
return 0;
}
if (type === 'boolean') return true;

return 'test';
Expand All @@ -63,6 +66,22 @@ function buildMinimalSample(
? new Set(schema.required as string[])
: new Set<string>();

// Merge required fields from the first anyOf/oneOf branch. Endpoints with
// conditional requirements (e.g. "one of contentBase64 or contentText")
// express them this way — pick the first branch so the probe body passes
// validation and the x402 paywall can fire.
const compositeBranches = (schema.anyOf ?? schema.oneOf) as
| Record<string, unknown>[]
| undefined;
if (Array.isArray(compositeBranches) && compositeBranches.length > 0) {
const firstBranch = compositeBranches[0];
if (firstBranch && Array.isArray(firstBranch.required)) {
for (const key of firstBranch.required as string[]) {
required.add(key);
}
}
}

// Only fill properties explicitly marked required. If the merchant doesn't
// mark required fields, that's their spec to fix.
const keys = Object.keys(properties).filter(k => required.has(k));
Expand Down Expand Up @@ -101,16 +120,21 @@ export function buildMinimalSampleFromInputSchema(

const schema = inputSchema as Record<string, unknown>;

// The inputSchema from OpenAPI advisories wraps the body schema under
// `body.content["application/json"].schema` or may be the schema directly.
// The inputSchema from discovery advisories can arrive in several shapes:
// 1. `body.content["application/json"].schema` — full OpenAPI wrapper
// 2. `requestBody` — flattened wrapper from @agentcash/discovery
// 3. Direct schema with `properties` at top level
const bodyContent = (schema.body as Record<string, unknown>)?.content as
| Record<string, unknown>
| undefined;
const jsonSchema = (
bodyContent?.['application/json'] as Record<string, unknown>
)?.schema as Record<string, unknown> | undefined;
const requestBodySchema = schema.requestBody as
| Record<string, unknown>
| undefined;

const effectiveSchema = jsonSchema ?? schema;
const effectiveSchema = jsonSchema ?? requestBodySchema ?? schema;

return buildMinimalSample(effectiveSchema);
}
Expand Down
1 change: 1 addition & 0 deletions apps/scan/src/lib/discovery/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ export {
buildMinimalSampleFromInputSchema,
buildMinimalQueryParamsFromInputSchema,
} from './build-minimal-sample';
export { hasPathParameters } from './path-params';
48 changes: 48 additions & 0 deletions apps/scan/src/lib/discovery/utils/path-params.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { describe, it, expect } from 'vitest';
import { hasPathParameters } from './path-params';

describe('hasPathParameters', () => {
it('detects raw braces in URL path', () => {
expect(
hasPathParameters(
'https://store.nosub.club/v1/files/public/{blobId}/extend'
)
).toBe(true);
});

it('detects URL-encoded braces', () => {
expect(
hasPathParameters(
'https://store.nosub.club/v1/files/public/%7BblobId%7D/extend'
)
).toBe(true);
});

it('detects case-insensitive encoded braces', () => {
expect(
hasPathParameters('https://store.nosub.club/v1/files/%7bblobId%7d/extend')
).toBe(true);
});

it('returns false for regular URLs', () => {
expect(hasPathParameters('https://store.nosub.club/v1/files/public')).toBe(
false
);
});

it('returns false for URLs with braces in query params only', () => {
expect(hasPathParameters('https://example.com/api?filter={name}')).toBe(
false
);
});

it('handles multiple path parameters', () => {
expect(
hasPathParameters('https://api.example.com/{org}/{repo}/issues')
).toBe(true);
});

it('handles bare paths without protocol', () => {
expect(hasPathParameters('/v1/files/{id}')).toBe(true);
});
});
13 changes: 13 additions & 0 deletions apps/scan/src/lib/discovery/utils/path-params.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/**
* Detects whether a URL contains OpenAPI-style path parameters.
* Checks for both raw `{param}` and URL-encoded `%7Bparam%7D` forms
* since discovery may produce either depending on how the URL is resolved.
*/
export function hasPathParameters(url: string): boolean {
try {
const pathname = new URL(url).pathname;
return /{[^}]+}/.test(pathname) || /%7B[^%]+%7D/i.test(pathname);
} catch {
return /{[^}]+}/.test(url) || /%7B[^%]+%7D/i.test(url);
}
}
Loading