diff --git a/apps/scan/src/app/api/v1/resources/refresh/route.ts b/apps/scan/src/app/api/v1/resources/refresh/route.ts new file mode 100644 index 000000000..2ea8f7317 --- /dev/null +++ b/apps/scan/src/app/api/v1/resources/refresh/route.ts @@ -0,0 +1,134 @@ +import { NextResponse, type NextRequest } from 'next/server'; +import { z } from 'zod'; + +import { probeX402Endpoint } from '@/lib/discovery/probe'; +import { registerResource } from '@/lib/resources'; + +const refreshBodySchema = z.object({ + url: z.string().url('A valid URL is required'), +}); + +const corsHeaders: Record = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type', +}; + +function jsonResponse(data: unknown, status = 200): NextResponse { + return NextResponse.json( + JSON.parse( + JSON.stringify(data, (_k, v: unknown) => + typeof v === 'bigint' ? Number(v) : v + ) + ), + { status, headers: corsHeaders } + ); +} + +export async function OPTIONS() { + return new Response(null, { status: 204, headers: corsHeaders }); +} + +/** + * POST /api/v1/resources/refresh + * + * Re-probe an existing resource and update its registration data. + * This allows resource providers to trigger a refresh after updating + * their offering, ensuring x402scan displays the latest information. + * + * Body: { "url": "https://example.com/api/resource" } + */ +export async function POST(request: NextRequest) { + let body: unknown; + try { + body = await request.json(); + } catch { + return jsonResponse( + { + success: false, + error: { + type: 'invalid_json', + message: 'Request body must be valid JSON', + }, + }, + 400 + ); + } + + const parsed = refreshBodySchema.safeParse(body); + if (!parsed.success) { + return jsonResponse( + { + success: false, + error: { + type: 'validation_error', + message: 'Invalid request body', + details: parsed.error.issues, + }, + }, + 400 + ); + } + + const { url } = parsed.data; + + try { + const probeResult = await probeX402Endpoint( + url.replaceAll('{', '').replaceAll('}', '') + ); + + if (!probeResult.success) { + return jsonResponse( + { + success: false, + error: { + type: 'no_402', + message: + 'URL did not return a 402 Payment Required response. Ensure the resource is still x402-protected.', + details: probeResult.error, + }, + }, + 422 + ); + } + + const result = await registerResource(url, probeResult.advisory); + + if (!result.success) { + return jsonResponse( + { + success: false, + error: { + type: 'parse_error', + message: 'Failed to parse x402 response during refresh', + parseErrors: + result.error.type === 'parseResponse' + ? result.error.parseErrors + : [JSON.stringify(result.error)], + }, + }, + 422 + ); + } + + return jsonResponse({ + success: true, + message: 'Resource refreshed successfully', + resource: result.resource, + accepts: result.accepts, + registrationDetails: result.registrationDetails, + }); + } catch (error) { + console.error('Resource refresh failed:', error); + return jsonResponse( + { + success: false, + error: { + type: 'internal_error', + message: 'An unexpected error occurred during refresh', + }, + }, + 500 + ); + } +} diff --git a/apps/scan/src/app/api/v1/resources/register-origin/route.ts b/apps/scan/src/app/api/v1/resources/register-origin/route.ts new file mode 100644 index 000000000..1789463f0 --- /dev/null +++ b/apps/scan/src/app/api/v1/resources/register-origin/route.ts @@ -0,0 +1,120 @@ +import { NextResponse, type NextRequest } from 'next/server'; +import { z } from 'zod'; + +import { fetchDiscoveryDocument } from '@/services/discovery'; +import { registerResourcesFromDiscovery } from '@/lib/discovery/register-origin'; + +const registerOriginBodySchema = z.object({ + origin: z.string().url('A valid origin URL is required'), +}); + +const corsHeaders: Record = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type', +}; + +function jsonResponse(data: unknown, status = 200): NextResponse { + return NextResponse.json( + JSON.parse( + JSON.stringify(data, (_k, v: unknown) => + typeof v === 'bigint' ? Number(v) : v + ) + ), + { status, headers: corsHeaders } + ); +} + +export async function OPTIONS() { + return new Response(null, { status: 204, headers: corsHeaders }); +} + +/** + * POST /api/v1/resources/register-origin + * + * Discover and register all x402-protected resources from an origin. + * Uses DNS TXT records (_x402.{hostname}) or /.well-known/x402 for discovery. + * + * Body: { "origin": "https://example.com" } + */ +export async function POST(request: NextRequest) { + let body: unknown; + try { + body = await request.json(); + } catch { + return jsonResponse( + { + success: false, + error: { + type: 'invalid_json', + message: 'Request body must be valid JSON', + }, + }, + 400 + ); + } + + const parsed = registerOriginBodySchema.safeParse(body); + if (!parsed.success) { + return jsonResponse( + { + success: false, + error: { + type: 'validation_error', + message: 'Invalid request body', + details: parsed.error.issues, + }, + }, + 400 + ); + } + + const { origin } = parsed.data; + + try { + const discoveryResult = await fetchDiscoveryDocument(origin); + + if (!discoveryResult.success) { + return jsonResponse( + { + success: false, + error: { + type: 'no_discovery', + message: + discoveryResult.error ?? 'No discovery document found at origin', + }, + }, + 404 + ); + } + + const result = await registerResourcesFromDiscovery( + discoveryResult.resources, + discoveryResult.source + ); + + return jsonResponse({ + success: true, + registered: result.registered, + failed: result.failed, + skipped: result.skipped, + deprecated: result.deprecated, + total: result.total, + source: result.source, + failedDetails: + result.failedDetails.length > 0 ? result.failedDetails : undefined, + }); + } catch (error) { + console.error('Origin registration failed:', error); + return jsonResponse( + { + success: false, + error: { + type: 'internal_error', + message: 'An unexpected error occurred during origin registration', + }, + }, + 500 + ); + } +} diff --git a/apps/scan/src/app/api/v1/resources/register/route.ts b/apps/scan/src/app/api/v1/resources/register/route.ts new file mode 100644 index 000000000..74a1de782 --- /dev/null +++ b/apps/scan/src/app/api/v1/resources/register/route.ts @@ -0,0 +1,133 @@ +import { NextResponse, type NextRequest } from 'next/server'; +import { z } from 'zod'; + +import { probeX402Endpoint } from '@/lib/discovery/probe'; +import { registerResource } from '@/lib/resources'; + +const registerBodySchema = z.object({ + url: z.string().url('A valid URL is required'), +}); + +const corsHeaders: Record = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type', +}; + +function jsonResponse(data: unknown, status = 200): NextResponse { + return NextResponse.json( + JSON.parse( + JSON.stringify(data, (_k, v: unknown) => + typeof v === 'bigint' ? Number(v) : v + ) + ), + { status, headers: corsHeaders } + ); +} + +export async function OPTIONS() { + return new Response(null, { status: 204, headers: corsHeaders }); +} + +/** + * POST /api/v1/resources/register + * + * Register a single x402-protected resource by URL. + * The endpoint probes the URL for a 402 response, parses payment info, + * and registers it on x402scan. + * + * Body: { "url": "https://example.com/api/resource" } + */ +export async function POST(request: NextRequest) { + let body: unknown; + try { + body = await request.json(); + } catch { + return jsonResponse( + { + success: false, + error: { + type: 'invalid_json', + message: 'Request body must be valid JSON', + }, + }, + 400 + ); + } + + const parsed = registerBodySchema.safeParse(body); + if (!parsed.success) { + return jsonResponse( + { + success: false, + error: { + type: 'validation_error', + message: 'Invalid request body', + details: parsed.error.issues, + }, + }, + 400 + ); + } + + const { url } = parsed.data; + + try { + const probeResult = await probeX402Endpoint( + url.replaceAll('{', '').replaceAll('}', '') + ); + + if (!probeResult.success) { + return jsonResponse( + { + success: false, + error: { + type: 'no_402', + message: 'URL did not return a 402 Payment Required response', + details: probeResult.error, + }, + }, + 422 + ); + } + + const result = await registerResource(url, probeResult.advisory); + + if (!result.success) { + return jsonResponse( + { + success: false, + error: { + type: 'parse_error', + message: 'Failed to parse x402 response', + parseErrors: + result.error.type === 'parseResponse' + ? result.error.parseErrors + : [JSON.stringify(result.error)], + }, + data: result.data, + }, + 422 + ); + } + + return jsonResponse({ + success: true, + resource: result.resource, + accepts: result.accepts, + registrationDetails: result.registrationDetails, + }); + } catch (error) { + console.error('Resource registration failed:', error); + return jsonResponse( + { + success: false, + error: { + type: 'internal_error', + message: 'An unexpected error occurred during registration', + }, + }, + 500 + ); + } +} diff --git a/apps/scan/src/lib/router.ts b/apps/scan/src/lib/router.ts index 507678ad0..b4c863d6c 100644 --- a/apps/scan/src/lib/router.ts +++ b/apps/scan/src/lib/router.ts @@ -49,6 +49,11 @@ export const router = createRouter({ - POST /api/x402/registry/register — register a single x402 resource by URL - POST /api/x402/registry/register-origin — discover and register all resources from an origin via OpenAPI or .well-known/x402 +## Public programmatic API (no auth, free) +- POST /api/v1/resources/register — register a single x402 resource by URL (JSON body: {"url": "..."}) +- POST /api/v1/resources/register-origin — discover and register all x402 resources from an origin (JSON body: {"origin": "..."}) +- POST /api/v1/resources/refresh — re-probe and update an existing resource (JSON body: {"url": "..."}) + ## Send endpoint (x402, dynamic price) - POST /api/x402/send — send USDC to an address on Base or Solana`, }, diff --git a/docs/PROGRAMMATIC_API.md b/docs/PROGRAMMATIC_API.md new file mode 100644 index 000000000..cec5ff053 --- /dev/null +++ b/docs/PROGRAMMATIC_API.md @@ -0,0 +1,199 @@ +# Programmatic Resource Registration API + +x402scan exposes a public REST API that allows resource providers to register and refresh their x402-protected resources programmatically — no wallet authentication required. + +## Base URL + +``` +https://x402scan.com/api/v1 +``` + +## Endpoints + +### Register a Single Resource + +``` +POST /api/v1/resources/register +``` + +Register a single x402-protected resource by URL. The endpoint probes the URL for a `402 Payment Required` response, parses the payment information, and adds it to the x402scan registry. + +**Request Body:** + +```json +{ + "url": "https://your-server.com/api/paid-endpoint" +} +``` + +**Success Response (200):** + +```json +{ + "success": true, + "resource": { + "id": "...", + "resource": "https://your-server.com/api/paid-endpoint", + "origin": { "id": "...", "origin": "https://your-server.com" } + }, + "accepts": [...], + "registrationDetails": { ... } +} +``` + +**Error Response (422):** + +```json +{ + "success": false, + "error": { + "type": "no_402", + "message": "URL did not return a 402 Payment Required response" + } +} +``` + +### Register All Resources from an Origin + +``` +POST /api/v1/resources/register-origin +``` + +Discover and register all x402-protected resources from an origin. Uses [OpenAPI discovery](./DISCOVERY.md), DNS TXT records (`_x402.{hostname}`), or `/.well-known/x402` to find resources. + +**Request Body:** + +```json +{ + "origin": "https://your-server.com" +} +``` + +**Success Response (200):** + +```json +{ + "success": true, + "registered": 5, + "failed": 0, + "skipped": 1, + "deprecated": 0, + "total": 6, + "source": "openapi" +} +``` + +### Refresh a Resource + +``` +POST /api/v1/resources/refresh +``` + +Re-probe an existing resource and update its registration data. Use this after updating your server's pricing, payment options, or schema to ensure x402scan reflects the latest configuration. + +**Request Body:** + +```json +{ + "url": "https://your-server.com/api/paid-endpoint" +} +``` + +**Success Response (200):** + +```json +{ + "success": true, + "message": "Resource refreshed successfully", + "resource": { ... }, + "accepts": [...], + "registrationDetails": { ... } +} +``` + +## Error Responses + +All endpoints return errors in a consistent format: + +| Status | Type | Description | +|--------|------|-------------| +| 400 | `invalid_json` | Request body is not valid JSON | +| 400 | `validation_error` | Missing or invalid fields in request body | +| 404 | `no_discovery` | No discovery document found at the origin | +| 422 | `no_402` | URL did not return a 402 Payment Required response | +| 422 | `parse_error` | Failed to parse the x402 payment response | +| 500 | `internal_error` | Unexpected server error | + +## Examples + +### cURL + +```bash +# Register a single resource +curl -X POST https://x402scan.com/api/v1/resources/register \ + -H "Content-Type: application/json" \ + -d '{"url": "https://your-server.com/api/paid-endpoint"}' + +# Register all resources from an origin +curl -X POST https://x402scan.com/api/v1/resources/register-origin \ + -H "Content-Type: application/json" \ + -d '{"origin": "https://your-server.com"}' + +# Refresh a resource after updating pricing +curl -X POST https://x402scan.com/api/v1/resources/refresh \ + -H "Content-Type: application/json" \ + -d '{"url": "https://your-server.com/api/paid-endpoint"}' +``` + +### JavaScript / TypeScript + +```typescript +// Register a resource +const response = await fetch('https://x402scan.com/api/v1/resources/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url: 'https://your-server.com/api/paid-endpoint' }), +}); +const result = await response.json(); + +if (result.success) { + console.log('Registered:', result.resource.id); +} else { + console.error('Failed:', result.error.message); +} +``` + +### Python + +```python +import requests + +# Register a resource +response = requests.post( + "https://x402scan.com/api/v1/resources/register", + json={"url": "https://your-server.com/api/paid-endpoint"}, +) +result = response.json() + +if result["success"]: + print(f"Registered: {result['resource']['id']}") +else: + print(f"Failed: {result['error']['message']}") +``` + +## CI/CD Integration + +You can integrate resource registration into your deployment pipeline to automatically update x402scan when you deploy changes: + +```yaml +# GitHub Actions example +- name: Register resources on x402scan + run: | + curl -X POST https://x402scan.com/api/v1/resources/register-origin \ + -H "Content-Type: application/json" \ + -d '{"origin": "${{ env.SERVER_URL }}"}' +``` + +## CORS + +All endpoints support CORS with `Access-Control-Allow-Origin: *`, so they can be called from browser-based applications.