diff --git a/apps/scan/src/app/(app)/(home)/resources/register/_components/form.tsx b/apps/scan/src/app/(app)/(home)/resources/register/_components/form.tsx index 893f5a863..dc1505891 100644 --- a/apps/scan/src/app/(app)/(home)/resources/register/_components/form.tsx +++ b/apps/scan/src/app/(app)/(home)/resources/register/_components/form.tsx @@ -32,7 +32,9 @@ import { } from '@/app/(app)/_components/discovery'; import { Favicon } from '@/app/(app)/_components/favicon'; import { normalizeUrl } from '@/lib/url'; +import { resourceKey } from '@/lib/resource-key'; import { api } from '@/trpc/client'; +import type { DiscoveredResource } from '@/types/discovery'; import Link from 'next/link'; import { z } from 'zod'; @@ -89,6 +91,9 @@ function toPathLabel(resourceUrl: string): string { } } +const rk = (r: { url: string; method?: string }) => + resourceKey(r.url, r.method); + function getPrimaryProbeError( failed?: { error: string; @@ -721,70 +726,73 @@ function ProbeResult({ description?: string | null; } | null; urlOrigin: string | null; - resources: string[]; - testedResources?: { url: string; warnings?: { code: string }[] }[]; - failedResources?: { url: string }[]; + resources: DiscoveredResource[]; + testedResources?: { + url: string; + method?: string; + warnings?: { code: string }[]; + }[]; + failedResources?: { url: string; method?: string }[]; isBatchTestLoading?: boolean; authModeMap?: Record; invalidResourcesMap?: Record; }) { - const testedUrls = useMemo( - () => new Set(testedResources.map(r => r.url)), + const testedKeys = useMemo( + () => new Set(testedResources.map(rk)), [testedResources] ); - const warningUrls = useMemo( + const warningKeys = useMemo( () => new Set( - testedResources - .filter(r => r.warnings && r.warnings.length > 0) - .map(r => r.url) + testedResources.filter(r => r.warnings && r.warnings.length > 0).map(rk) ), [testedResources] ); - const failedUrls = useMemo( - () => new Set(failedResources.map(r => r.url)), + const failedKeys = useMemo( + () => new Set(failedResources.map(rk)), [failedResources] ); - const siwxUrls = useMemo( + const siwxKeys = useMemo( () => new Set( Object.entries(authModeMap) .filter(([, mode]) => mode === 'siwx') - .map(([url]) => url) + .map(([key]) => key) ), [authModeMap] ); - const nonPaidUrls = useMemo(() => { + const nonPaidKeys = useMemo(() => { const paid = new Set(['paid', 'apiKey+paid']); return new Set( - resources.filter(url => { - const mode = authModeMap[url]; - // Only mark as non-paid if discovery explicitly classified it - // as non-paid. If authMode is missing, don't pre-judge. - return mode !== undefined && mode !== 'siwx' && !paid.has(mode); - }) + resources + .filter(r => { + const mode = authModeMap[rk(r)]; + return mode !== undefined && mode !== 'siwx' && !paid.has(mode); + }) + .map(rk) ); }, [resources, authModeMap]); - const invalidUrls = useMemo( + const invalidKeys = useMemo( () => new Set( Object.entries(invalidResourcesMap) .filter(([, info]) => info.invalid) - .map(([url]) => url) + .map(([key]) => key) ), [invalidResourcesMap] ); // Sort: errors → warnings → free (SIWX) → verified → skipped const sortedResources = useMemo(() => { - const priority = (url: string) => { - if (invalidUrls.has(url) || failedUrls.has(url)) return 0; - if (warningUrls.has(url)) return 1; - if (siwxUrls.has(url)) return 2; - if (testedUrls.has(url)) return 3; + const priority = (r: DiscoveredResource) => { + const k = rk(r); + if (invalidKeys.has(k) || failedKeys.has(k)) return 0; + if (warningKeys.has(k)) return 1; + if (siwxKeys.has(k)) return 2; + if (testedKeys.has(k)) return 3; return 4; // non-paid — skipped }; return [...resources].sort((a, b) => priority(a) - priority(b)); - }, [resources, invalidUrls, failedUrls, warningUrls, siwxUrls, testedUrls]); + }, [resources, invalidKeys, failedKeys, warningKeys, siwxKeys, testedKeys]); const [expanded, setExpanded] = useState(false); const previewResources = expanded @@ -794,6 +802,16 @@ function ProbeResult({ ? 0 : sortedResources.length - previewResources.length; + // Check if any URLs appear with multiple methods — show method badges when needed + const showMethodBadges = useMemo(() => { + const urls = new Set(); + for (const r of resources) { + if (urls.has(r.url)) return true; + urls.add(r.url); + } + return false; + }, [resources]); + return (
@@ -850,35 +868,43 @@ function ProbeResult({ className="w-full text-left" >
    - {previewResources.map(resource => ( -
  • - {nonPaidUrls.has(resource) ? ( - - ) : invalidUrls.has(resource) ? ( - - ) : siwxUrls.has(resource) ? ( - - ) : warningUrls.has(resource) ? ( - - ) : testedUrls.has(resource) ? ( - - ) : failedUrls.has(resource) ? ( - - ) : null} - { + const k = rk(resource); + return ( +
  • - {toPathLabel(resource)} - -
  • - ))} + {nonPaidKeys.has(k) ? ( + + ) : invalidKeys.has(k) ? ( + + ) : siwxKeys.has(k) ? ( + + ) : warningKeys.has(k) ? ( + + ) : testedKeys.has(k) ? ( + + ) : failedKeys.has(k) ? ( + + ) : null} + + {showMethodBadges && resource.method && ( + + {resource.method} + + )} + {toPathLabel(resource.url)} + + + ); + })} {!expanded && hiddenCount > 0 && (
  • + {hiddenCount} more diff --git a/apps/scan/src/app/(app)/_components/discovery/discovery-panel.tsx b/apps/scan/src/app/(app)/_components/discovery/discovery-panel.tsx index cedfd85a7..0205836a1 100644 --- a/apps/scan/src/app/(app)/_components/discovery/discovery-panel.tsx +++ b/apps/scan/src/app/(app)/_components/discovery/discovery-panel.tsx @@ -40,7 +40,12 @@ import type { FailedResource as FailedResourceType, TestedResource as TestedResourceType, } from '@/types/batch-test'; -import type { AuthMode, DiscoverySource } from '@/types/discovery'; +import type { + AuthMode, + DiscoveredResource, + DiscoverySource, +} from '@/types/discovery'; +import { resourceKey } from '@/lib/resource-key'; import type { Methods } from '@/types/x402'; import type { OgImage, ResourceOrigin } from '@x402scan/scan-db/types'; @@ -117,8 +122,8 @@ export interface DiscoveryPanelProps { found: boolean; /** Discovery source from discovery runtime */ source?: DiscoverySource; - /** List of discovered resource URLs */ - resources: string[]; + /** List of discovered resources */ + resources: DiscoveredResource[]; /** Total count of resources */ resourceCount: number; /** Error message when discovery failed */ @@ -570,9 +575,13 @@ export function DiscoveryPanel({ return null; } - // Create maps for quick lookup - const testedResourceMap = new Map(testedResources.map(r => [r.url, r])); - const failedResourceMap = new Map(failedResources.map(r => [r.url, r])); + // Create maps for quick lookup (keyed by composite key for method awareness) + const testedResourceMap = new Map( + testedResources.map(r => [resourceKey(r.url, r.method), r]) + ); + const failedResourceMap = new Map( + failedResources.map(r => [resourceKey(r.url, r.method), r]) + ); // Pagination const totalPages = Math.ceil(resources.length / ITEMS_PER_PAGE); @@ -625,10 +634,11 @@ export function DiscoveryPanel({
) : (
- {paginatedResources.map((resourceUrl, idx) => { - const tested = testedResourceMap.get(resourceUrl); - const invalidInfo = invalidResourcesMap[resourceUrl]; - const authMode = authModeMap[resourceUrl]; + {paginatedResources.map((resource, idx) => { + const k = resourceKey(resource.url, resource.method); + const tested = testedResourceMap.get(k); + const invalidInfo = invalidResourcesMap[k]; + const authMode = authModeMap[k]; if (tested) { // Check if we have a valid schema for the resource card @@ -638,8 +648,8 @@ export function DiscoveryPanel({ // Render working resource with ResourceExecutor return ( ; @@ -1177,6 +1187,7 @@ function RegisterModeResourceList({ // Build unified list: entered URL first (if exists), then discovered const allResources: { url: string; + method?: string; source: 'entered' | DiscoverySource; isRegistered: boolean; }[] = []; @@ -1189,21 +1200,33 @@ function RegisterModeResourceList({ }); } - for (const url of discoveredResources) { + for (const r of discoveredResources) { allResources.push({ - url, + url: r.url, + method: r.method, source: source ?? 'well-known', - isRegistered: registeredSet.has(url), + isRegistered: registeredSet.has(r.url), }); } if (allResources.length === 0) return null; + // Check if any URLs appear with multiple methods + const showMethodColumn = (() => { + const urls = new Set(); + for (const r of allResources) { + if (urls.has(r.url)) return true; + urls.add(r.url); + } + return false; + })(); + // Sort: invalid → free (SIWX) → new → already registered const sortedResources = [...allResources].sort((a, b) => { const priority = (r: (typeof allResources)[number]) => { - if (invalidResourcesMap[r.url]?.invalid) return 0; - if (authModeMap[r.url] === 'siwx') return 1; + const k = resourceKey(r.url, r.method); + if (invalidResourcesMap[k]?.invalid) return 0; + if (authModeMap[k] === 'siwx') return 1; if (!r.isRegistered) return 2; return 3; // already registered }; @@ -1216,13 +1239,17 @@ function RegisterModeResourceList({ Resource + {showMethodColumn && ( + Method + )} Source Status {sortedResources.map( - ({ url, source: resourceSource, isRegistered }) => { + ({ url, method, source: resourceSource, isRegistered }) => { + const k = resourceKey(url, method); const pathname = (() => { try { return decodeURIComponent(new URL(url).pathname); @@ -1232,10 +1259,19 @@ function RegisterModeResourceList({ })(); return ( - + {pathname} + {showMethodColumn && ( + + {method && ( + + {method} + + )} + + )} )} - {authModeMap[url] === 'siwx' && ( + {authModeMap[k] === 'siwx' && ( @@ -1284,7 +1320,7 @@ function RegisterModeResourceList({ )} - {invalidResourcesMap[url]?.invalid && ( + {invalidResourcesMap[k]?.invalid && ( @@ -1294,7 +1330,7 @@ function RegisterModeResourceList({

- {invalidResourcesMap[url]?.reason ?? + {invalidResourcesMap[k]?.reason ?? 'Invalid format'}

diff --git a/apps/scan/src/app/(app)/_components/discovery/use-discovery.ts b/apps/scan/src/app/(app)/_components/discovery/use-discovery.ts index 01e08482d..9d97301da 100644 --- a/apps/scan/src/app/(app)/_components/discovery/use-discovery.ts +++ b/apps/scan/src/app/(app)/_components/discovery/use-discovery.ts @@ -5,6 +5,7 @@ import z from 'zod'; import { api } from '@/trpc/client'; import { useRegisterFromOrigin } from '@/hooks/use-register-from-origin'; +import { resourceKey } from '@/lib/resource-key'; import type { FailedResource, TestedResource } from '@/types/batch-test'; import type { @@ -65,8 +66,8 @@ export interface UseDiscoveryReturn { isDiscoveryLoading: boolean; discoveryFound: boolean; discoverySource?: DiscoverySource; - discoveryResources: string[]; - actualDiscoveredResources: string[]; + discoveryResources: DiscoveredResource[]; + actualDiscoveredResources: DiscoveredResource[]; skippedResources: { url: string; authMode?: string }[]; siwxResourceCount: number; discoveryResourceCount: number; @@ -225,17 +226,12 @@ export function useDiscovery({ } ); - // Extract URLs for display (string[]) - const resourceUrls = useMemo( - () => effectiveResources.map(r => r.url), + // Extract URLs for the checkRegistered query + const resourceInputs = useMemo( + () => effectiveResources.map(r => ({ url: r.url, method: r.method })), [effectiveResources] ); - // Only the actually discovered resources (not the prepended user-entered URL) - const actualDiscoveredUrls = useMemo( - () => discoveryResources.map(r => r.url), - [discoveryResources] - ); - // Create map of URL -> invalid status for displaying badges + // Create map of compositeKey -> invalid status for displaying badges const invalidResourcesMap: Record< string, { invalid: boolean; reason?: string } @@ -249,18 +245,18 @@ export function useDiscovery({ if (resource.invalidReason) { entry.reason = resource.invalidReason; } - map[resource.url] = entry; + map[resourceKey(resource.url, resource.method)] = entry; } } return map; }, [effectiveResources]); - // Create map of URL -> authMode for displaying auth badges (e.g. SIWX). + // Create map of compositeKey -> authMode for displaying auth badges (e.g. SIWX). const authModeMap: Record = useMemo(() => { const map: Record = {}; for (const resource of effectiveResources) { if (resource.authMode) { - map[resource.url] = resource.authMode; + map[resourceKey(resource.url, resource.method)] = resource.authMode; } } return map; @@ -281,9 +277,9 @@ export function useDiscovery({ // Check which resources are already registered const registeredCheckQuery = api.public.resources.checkRegistered.useQuery( - { urls: resourceUrls }, + { resources: resourceInputs }, { - enabled: discoveryCheckComplete && resourceUrls.length > 0, + enabled: discoveryCheckComplete && resourceInputs.length > 0, staleTime: 60000, // Cache for 1 min } ); @@ -329,8 +325,8 @@ export function useDiscovery({ discoverySource: discoveryQuery.data?.found ? discoveryQuery.data.source : undefined, - discoveryResources: resourceUrls, - actualDiscoveredResources: actualDiscoveredUrls, + discoveryResources: effectiveResources, + actualDiscoveredResources: discoveryResources, skippedResources, siwxResourceCount: discoveryResources.filter(r => r.authMode === 'siwx') .length, diff --git a/apps/scan/src/app/(app)/_components/resources/origin-resources.tsx b/apps/scan/src/app/(app)/_components/resources/origin-resources.tsx index c8ae82f0e..bebc34db6 100644 --- a/apps/scan/src/app/(app)/_components/resources/origin-resources.tsx +++ b/apps/scan/src/app/(app)/_components/resources/origin-resources.tsx @@ -12,6 +12,7 @@ import { ResourceCard, LoadingResourceCard } from './resource-card'; import { getBazaarMethod, isSiwxResource } from './utils'; import { serializeAccepts } from '@/lib/token'; +import { Methods } from '@/types/x402'; import type { RouterOutputs } from '@/trpc/client'; @@ -55,7 +56,12 @@ export const OriginResources: React.FC = ({ const rawOutputSchema = resource.accepts.find( accept => accept.outputSchema )?.outputSchema; - const bazaarMethod = getBazaarMethod(rawOutputSchema); + // Prefer the method stored in the DB (from discovery) over the + // inferred method from the x402 schema (which defaults to POST). + const bazaarMethod = + resource.method in Methods + ? (resource.method as Methods) + : getBazaarMethod(rawOutputSchema); return ( { it('parses integer min from range', () => { @@ -162,3 +164,33 @@ describe('formatPricingLabel', () => { ).toBe('$50.00–$500.00'); }); }); + +describe('getBazaarMethod', () => { + it('returns explicit method from input schema', () => { + expect(getBazaarMethod({ input: { method: 'GET' } })).toBe(Methods.GET); + expect(getBazaarMethod({ input: { method: 'post' } })).toBe(Methods.POST); + }); + + it('infers POST when body exists', () => { + expect(getBazaarMethod({ input: { body: { type: 'object' } } })).toBe( + Methods.POST + ); + }); + + it('infers POST when bodyFields exists (v1)', () => { + expect( + getBazaarMethod({ input: { bodyFields: { name: { type: 'string' } } } }) + ).toBe(Methods.POST); + }); + + it('infers GET when only queryParams exists', () => { + expect( + getBazaarMethod({ input: { queryParams: { q: { type: 'string' } } } }) + ).toBe(Methods.GET); + }); + + it('defaults to GET for null/undefined schema', () => { + expect(getBazaarMethod(null)).toBe(Methods.GET); + expect(getBazaarMethod(undefined)).toBe(Methods.GET); + }); +}); diff --git a/apps/scan/src/lib/discovery/register-origin.ts b/apps/scan/src/lib/discovery/register-origin.ts index 3c7ed0765..bbec99234 100644 --- a/apps/scan/src/lib/discovery/register-origin.ts +++ b/apps/scan/src/lib/discovery/register-origin.ts @@ -131,9 +131,11 @@ export async function registerResourcesFromDiscovery( async function registerAsSiwx( resourceUrl: string, pricingMode?: string, - price?: string + price?: string, + method?: string ) { const siwxResult = await registerSiwxResource(resourceUrl, { + method, originMetadataFallback: originInfo, pricingMode, price, @@ -167,7 +169,12 @@ export async function registerResourcesFromDiscovery( } if (resource.authMode === 'siwx') { - return registerAsSiwx(resourceUrl, resource.pricingMode, resource.price); + return registerAsSiwx( + resourceUrl, + resource.pricingMode, + resource.price, + resource.method + ); } // Check server-side probe cache (from the batch test). This skips @@ -211,7 +218,12 @@ export async function registerResourcesFromDiscovery( // v1 rejection is handled inside registerResource() — no duplicate check needed here. if (advisory.authMode === 'siwx') { - return registerAsSiwx(resourceUrl, resource.pricingMode, resource.price); + return registerAsSiwx( + resourceUrl, + resource.pricingMode, + resource.price, + resource.method + ); } const result = await registerResource(resourceUrl, advisory, { @@ -220,6 +232,7 @@ export async function registerResourcesFromDiscovery( warnings: probeWarnings, pricingMode: resource.pricingMode, price: resource.price, + method: resource.method, }); if (result.success) return result; @@ -233,11 +246,12 @@ export async function registerResourcesFromDiscovery( const successfulResults: { url: string; + method: string; originId: string; title: string | null; description: string | null; }[] = []; - const siwxResults: { url: string }[] = []; + const siwxResults: { url: string; method: string }[] = []; const failedResults: { url: string; error: string; status?: number }[] = []; const skippedResults: { url: string; error: string; status?: number }[] = []; const warningResults: { @@ -249,6 +263,7 @@ export async function registerResourcesFromDiscovery( for (let i = 0; i < results.length; i++) { const result = results[i]; const resourceUrl = resources[i]?.url ?? 'unknown'; + const resourceMethod = resources[i]?.method ?? ''; if (!result) continue; @@ -256,7 +271,7 @@ export async function registerResourcesFromDiscovery( const value = result.value; if ('success' in value && value.success) { if ('siwx' in value && value.siwx === true) { - siwxResults.push({ url: resourceUrl }); + siwxResults.push({ url: resourceUrl, method: resourceMethod }); // Extract originId from SIWX registration result if (!originId && 'resource' in value && value.resource?.origin?.id) { originId = value.resource.origin.id; @@ -264,6 +279,7 @@ export async function registerResourcesFromDiscovery( } else if ('resource' in value) { successfulResults.push({ url: resourceUrl, + method: resourceMethod, originId: value.resource.origin.id, title: value.registrationDetails.originMetadata.title ?? null, description: @@ -326,8 +342,21 @@ export async function registerResourcesFromDiscovery( let deprecated = 0; if (originId) { - const activeResourceUrls = resources.map(r => normalizeResourceUrl(r.url)); - deprecated = await deprecateStaleResources(originId, activeResourceUrls); + // Build active list directly from successful registration results. + // Don't re-derive from the discovery input — it includes unprotected + // endpoints that share a URL with a registered endpoint (e.g. GET /campaigns + // is unprotected but POST /campaigns is paid). + const activeResources = [ + ...successfulResults.map(r => ({ + url: normalizeResourceUrl(r.url), + method: r.method, + })), + ...siwxResults.map(r => ({ + url: normalizeResourceUrl(r.url), + method: r.method, + })), + ]; + deprecated = await deprecateStaleResources(originId, activeResources); } const notifiedOrigins = new Set(); diff --git a/apps/scan/src/lib/resource-key.test.ts b/apps/scan/src/lib/resource-key.test.ts new file mode 100644 index 000000000..bf56c2ab0 --- /dev/null +++ b/apps/scan/src/lib/resource-key.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect } from 'vitest'; +import { resourceKey } from './resource-key'; + +describe('resourceKey', () => { + it('prefixes method when provided', () => { + expect(resourceKey('https://api.example.com/foo', 'GET')).toBe( + 'GET::https://api.example.com/foo' + ); + expect(resourceKey('https://api.example.com/foo', 'POST')).toBe( + 'POST::https://api.example.com/foo' + ); + }); + + it('returns bare URL when method is undefined', () => { + expect(resourceKey('https://api.example.com/foo')).toBe( + 'https://api.example.com/foo' + ); + expect(resourceKey('https://api.example.com/foo', undefined)).toBe( + 'https://api.example.com/foo' + ); + }); + + it('returns bare URL when method is empty string', () => { + expect(resourceKey('https://api.example.com/foo', '')).toBe( + 'https://api.example.com/foo' + ); + }); + + it('produces distinct keys for different methods on the same URL', () => { + const url = 'https://api.example.com/credits'; + const getKey = resourceKey(url, 'GET'); + const postKey = resourceKey(url, 'POST'); + expect(getKey).not.toBe(postKey); + }); + + it('produces same key for same method+URL', () => { + const url = 'https://api.example.com/credits'; + expect(resourceKey(url, 'POST')).toBe(resourceKey(url, 'POST')); + }); + + it('legacy (no method) and explicit method produce different keys', () => { + const url = 'https://api.example.com/credits'; + expect(resourceKey(url)).not.toBe(resourceKey(url, 'GET')); + }); +}); diff --git a/apps/scan/src/lib/resource-key.ts b/apps/scan/src/lib/resource-key.ts new file mode 100644 index 000000000..25a7369a3 --- /dev/null +++ b/apps/scan/src/lib/resource-key.ts @@ -0,0 +1,4 @@ +/** Stable composite key for a resource: `METHOD::url`. */ +export function resourceKey(url: string, method?: string): string { + return method ? `${method}::${url}` : url; +} diff --git a/apps/scan/src/lib/resources.ts b/apps/scan/src/lib/resources.ts index 38604cb9c..9c311b766 100644 --- a/apps/scan/src/lib/resources.ts +++ b/apps/scan/src/lib/resources.ts @@ -159,6 +159,7 @@ export function validateResource( export async function registerSiwxResource( url: string, options: { + method?: string; originMetadataFallback?: { title?: string; description?: string }; pricingMode?: string; price?: string; @@ -196,8 +197,11 @@ export async function registerSiwxResource( // Merge with existing metadata to avoid clobbering fields set by // a different registration path (e.g. paid sets pricingMode on the // same URL-keyed row). + const method = options.method ?? ''; const existing = await tx.resources.findUnique({ - where: { resource: cleanUrl }, + where: { + resource_method: { resource: cleanUrl, method }, + }, select: { metadata: true }, }); const mergedMetadata = @@ -208,9 +212,12 @@ export async function registerSiwxResource( : siwxMetadata; return tx.resources.upsert({ - where: { resource: cleanUrl }, + where: { + resource_method: { resource: cleanUrl, method }, + }, create: { resource: cleanUrl, + method, type: 'http', x402Version: 0, lastUpdated: new Date(), @@ -283,7 +290,12 @@ export async function registerSiwxResource( (error as { code: string }).code === 'P2002'; if (isUniqueViolation) { const existing = await scanDb.resources.findUnique({ - where: { resource: cleanUrl }, + where: { + resource_method: { + resource: cleanUrl, + method: options.method ?? '', + }, + }, include: { origin: true }, }); // Record may have been deleted between the P2002 and the lookup — @@ -320,6 +332,9 @@ export const registerResource = async ( pricingMode?: string; /** Price string from discovery document (e.g. "50-300.00 USD"). */ price?: string; + /** HTTP method from discovery — preferred over advisory.method which + * is always POST (x402 payment protocol). */ + method?: string; } = {} ) => { const validation = validateResource(url, advisory); @@ -456,6 +471,7 @@ export const registerResource = async ( const resource = await upsertResource({ resource: cleanUrl, + method: options.method ?? '', type: 'http', x402Version, lastUpdated: new Date(), diff --git a/apps/scan/src/services/db/resources/resource-schema.ts b/apps/scan/src/services/db/resources/resource-schema.ts index 718cbcfd7..6ff5e3427 100644 --- a/apps/scan/src/services/db/resources/resource-schema.ts +++ b/apps/scan/src/services/db/resources/resource-schema.ts @@ -9,6 +9,7 @@ import type { OutputSchema } from '@/lib/x402'; export const upsertResourceSchema = z.object({ resource: z.string(), + method: z.string().default(''), type: z.enum(['http']), x402Version: z.number(), lastUpdated: z.coerce.date(), diff --git a/apps/scan/src/services/db/resources/resource.test.ts b/apps/scan/src/services/db/resources/resource.test.ts index c8114c5dd..d1fd2eb40 100644 --- a/apps/scan/src/services/db/resources/resource.test.ts +++ b/apps/scan/src/services/db/resources/resource.test.ts @@ -2,7 +2,49 @@ import { describe, expect, it } from 'vitest'; import { upsertResourceSchema } from './resource-schema'; +const validAccepts = [ + { + scheme: 'exact', + network: 'eip155:8453', + payTo: '0x1234567890123456789012345678901234567890', + maxAmountRequired: '10000', + maxTimeoutSeconds: 60, + asset: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', + }, +]; + describe('upsertResourceSchema', () => { + it('defaults method to empty string when omitted', () => { + const result = upsertResourceSchema.safeParse({ + resource: 'https://api.example.com/foo', + type: 'http', + x402Version: 2, + lastUpdated: new Date(), + accepts: validAccepts, + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.method).toBe(''); + } + }); + + it('preserves explicit method', () => { + const result = upsertResourceSchema.safeParse({ + resource: 'https://api.example.com/foo', + method: 'POST', + type: 'http', + x402Version: 2, + lastUpdated: new Date(), + accepts: validAccepts, + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.method).toBe('POST'); + } + }); + it('accepts non-exact x402 schemes for supported networks', () => { const result = upsertResourceSchema.safeParse({ resource: 'https://api.example.com/upto', diff --git a/apps/scan/src/services/db/resources/resource.ts b/apps/scan/src/services/db/resources/resource.ts index 6eb56e784..700c09a54 100644 --- a/apps/scan/src/services/db/resources/resource.ts +++ b/apps/scan/src/services/db/resources/resource.ts @@ -56,7 +56,12 @@ export const upsertResource = async ( let mergedMetadata = baseResource.metadata; if (baseResource.metadata) { const existing = await tx.resources.findUnique({ - where: { resource: baseResource.resource }, + where: { + resource_method: { + resource: baseResource.resource, + method: baseResource.method, + }, + }, select: { metadata: true }, }); if ( @@ -70,10 +75,14 @@ export const upsertResource = async ( const { origin, ...resource } = await tx.resources.upsert({ where: { - resource: baseResource.resource, + resource_method: { + resource: baseResource.resource, + method: baseResource.method, + }, }, create: { resource: baseResource.resource, + method: baseResource.method, type: baseResource.type, x402Version: baseResource.x402Version, lastUpdated: baseResource.lastUpdated, @@ -414,44 +423,46 @@ export const listResourcesForTools = async (resourceIds: string[]) => { */ export const deprecateStaleResources = async ( originId: string, - activeResourceUrls: string[] + activeResources: { url: string; method: string }[] ) => { - if (activeResourceUrls.length === 0) { + if (activeResources.length === 0) { return 0; } - // Count how many active resources exist vs how many would be deprecated. - // If we'd wipe everything, it's almost certainly a bug (URL mismatch), - // not the origin legitimately removing all endpoints. - const activeCount = await scanDb.resources.count({ + // Fetch all non-deprecated resources for this origin with a simple indexed query. + // Compare in application code to find stale ones — avoids complex Prisma NOT/OR + // patterns that may generate unexpected SQL. + const allResources = await scanDb.resources.findMany({ where: { originId, deprecatedAt: null }, + select: { id: true, resource: true, method: true }, }); - if (activeCount === 0) { + if (allResources.length === 0) { return 0; } - const wouldDeprecateCount = await scanDb.resources.count({ - where: { - originId, - deprecatedAt: null, - resource: { notIn: activeResourceUrls }, - }, - }); + const activeKeys = new Set( + activeResources.map(r => `${r.method}::${r.url}`) + ); + const staleIds = allResources + .filter(r => !activeKeys.has(`${r.method}::${r.resource}`)) + .map(r => r.id); + + if (staleIds.length === 0) { + return 0; + } - if (wouldDeprecateCount === activeCount) { + // Safety: if we'd deprecate everything, it's almost certainly a bug + // (URL normalization mismatch), not the origin removing all endpoints. + if (staleIds.length === allResources.length) { console.warn( - `[deprecateStaleResources] Skipping: would deprecate all ${activeCount} active resources for origin ${originId}. Likely a URL normalization mismatch.` + `[deprecateStaleResources] Skipping: would deprecate all ${allResources.length} active resources for origin ${originId}. Likely a URL normalization mismatch.` ); return 0; } const result = await scanDb.resources.updateMany({ - where: { - originId, - deprecatedAt: null, - resource: { notIn: activeResourceUrls }, - }, + where: { id: { in: staleIds } }, data: { deprecatedAt: new Date() }, }); diff --git a/apps/scan/src/trpc/routers/public/resources.ts b/apps/scan/src/trpc/routers/public/resources.ts index 02b79e891..ececa3428 100644 --- a/apps/scan/src/trpc/routers/public/resources.ts +++ b/apps/scan/src/trpc/routers/public/resources.ts @@ -1,6 +1,7 @@ import z from 'zod'; import { revalidatePath } from 'next/cache'; +import { resourceKey } from '@/lib/resource-key'; import { createTRPCRouter, paginatedProcedure, @@ -246,30 +247,46 @@ export const resourcesRouter = createTRPCRouter({ }), /** - * Check which URLs are already registered. + * Check which resources are already registered. + * Accepts resources with optional method for compound-key matching. */ checkRegistered: publicProcedure .input( z.object({ - urls: z.array(z.string().url()).max(50), + resources: z + .array( + z.object({ + url: z.string().url(), + method: z.string().optional(), + }) + ) + .max(50), }) ) .query(async ({ input }) => { const registered = await scanDb.resources.findMany({ where: { - resource: { - in: input.urls, - }, + OR: input.resources.map(r => ({ + resource: r.url, + ...(r.method ? { method: r.method } : {}), + })), }, select: { resource: true, + method: true, }, }); - const registeredUrls = new Set(registered.map(r => r.resource)); + const registeredKeys = new Set( + registered.map(r => resourceKey(r.resource, r.method)) + ); return { - registered: input.urls.filter(url => registeredUrls.has(url)), - unregistered: input.urls.filter(url => !registeredUrls.has(url)), + registered: input.resources + .filter(r => registeredKeys.has(resourceKey(r.url, r.method))) + .map(r => r.url), + unregistered: input.resources + .filter(r => !registeredKeys.has(resourceKey(r.url, r.method))) + .map(r => r.url), }; }), diff --git a/apps/scan/src/types/batch-test.ts b/apps/scan/src/types/batch-test.ts index c0b843677..43f384c9c 100644 --- a/apps/scan/src/types/batch-test.ts +++ b/apps/scan/src/types/batch-test.ts @@ -15,6 +15,7 @@ export interface TestedResource { export interface FailedResource { success: false; url: string; + method?: string; error: string; issues?: AuditWarning[]; /** HTTP status code from the probe attempt, when available. */ diff --git a/packages/internal/databases/scan/prisma/migrations/20260602120000_add_method_to_resources/migration.sql b/packages/internal/databases/scan/prisma/migrations/20260602120000_add_method_to_resources/migration.sql new file mode 100644 index 000000000..e84f96112 --- /dev/null +++ b/packages/internal/databases/scan/prisma/migrations/20260602120000_add_method_to_resources/migration.sql @@ -0,0 +1,8 @@ +-- AlterTable: add method column (empty string = legacy, infer from schema) +ALTER TABLE "Resources" ADD COLUMN "method" TEXT NOT NULL DEFAULT ''; + +-- DropIndex: remove old single-column unique constraint +DROP INDEX "Resources_resource_key"; + +-- CreateIndex: add compound unique constraint on (resource, method) +CREATE UNIQUE INDEX "Resources_resource_method_key" ON "Resources"("resource", "method"); diff --git a/packages/internal/databases/scan/prisma/schema.prisma b/packages/internal/databases/scan/prisma/schema.prisma index f3d9c2999..1602f29e9 100644 --- a/packages/internal/databases/scan/prisma/schema.prisma +++ b/packages/internal/databases/scan/prisma/schema.prisma @@ -79,7 +79,8 @@ enum ResourceType { model Resources { id String @id @default(uuid()) - resource String @unique + resource String + method String @default("") type ResourceType x402Version Int lastUpdated DateTime @@ -101,6 +102,7 @@ model Resources { excluded ExcludedResource? metrics ResourceMetrics[] + @@unique([resource, method]) @@index([deprecatedAt]) } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 85e6ba434..488ebe30d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1004,8 +1004,6 @@ importers: specifier: 'catalog:' version: 5.9.3 - packages/internal/databases/scan/generated/client: {} - packages/internal/databases/transfers: dependencies: '@neondatabase/serverless': @@ -1052,8 +1050,6 @@ importers: specifier: 'catalog:' version: 5.9.3 - packages/internal/databases/transfers/generated/client: {} - packages/internal/neverthrow: dependencies: neverthrow: