Add x402 micropayments: pay-per-request screenshots via USDC on Base
SnapAPI now supports the x402 protocol for pay-per-request access. AI agents can pay $0.01/screenshot or $0.005/conversion with USDC on Base network - no signup or API key needed. - x402.js: Payment verification module using @x402/core + PayAI facilitator - server.js: Dual auth (x402 Payment-Signature OR X-API-Key) - Returns HTTP 402 with payment requirements for unauthenticated requests - API docs updated to v3.0.0 with x402 pricing info - CORS headers expose Payment-Required/Payment-Response Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
9361f9e4e2
commit
da6dc035ed
4344
package-lock.json
generated
4344
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -7,6 +7,9 @@
|
|||||||
"start": "node server.js"
|
"start": "node server.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@x402/core": "^2.3.0",
|
||||||
|
"@x402/evm": "^2.3.0",
|
||||||
|
"@x402/express": "^2.3.0",
|
||||||
"marked": "^17.0.1",
|
"marked": "^17.0.1",
|
||||||
"puppeteer-core": "^24.0.0"
|
"puppeteer-core": "^24.0.0"
|
||||||
},
|
},
|
||||||
|
|||||||
217
server.js
217
server.js
@ -6,6 +6,7 @@ const path = require('path');
|
|||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const { marked } = require('marked');
|
const { marked } = require('marked');
|
||||||
const payments = require('./payments');
|
const payments = require('./payments');
|
||||||
|
const x402 = require('./x402');
|
||||||
|
|
||||||
const PORT = process.env.PORT || 3001;
|
const PORT = process.env.PORT || 3001;
|
||||||
const CHROME_PATH = process.env.CHROME_PATH || '/usr/bin/google-chrome';
|
const CHROME_PATH = process.env.CHROME_PATH || '/usr/bin/google-chrome';
|
||||||
@ -65,6 +66,52 @@ function sendError(res, status, message) {
|
|||||||
sendJson(res, status, { error: message });
|
sendJson(res, status, { error: message });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Authenticate via x402 micropayment or API key
|
||||||
|
// Returns { ok: true, keyData, settleFn } or { ok: false } (response already sent)
|
||||||
|
async function authenticate(req, res, parsed) {
|
||||||
|
if (x402.hasX402Payment(req)) {
|
||||||
|
const payResult = await x402.processPayment(req);
|
||||||
|
if (!payResult.allowed) {
|
||||||
|
const headers = { 'Access-Control-Allow-Origin': '*', ...payResult.headers };
|
||||||
|
res.writeHead(payResult.status, headers);
|
||||||
|
res.end(payResult.isHtml ? payResult.body : JSON.stringify(payResult.body));
|
||||||
|
return { ok: false };
|
||||||
|
}
|
||||||
|
return { ok: true, keyData: null, settleFn: payResult.settle };
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiKey = req.headers['x-api-key'] || parsed.query.api_key;
|
||||||
|
if (!apiKey) {
|
||||||
|
// No auth - return x402 payment requirements
|
||||||
|
const payResult = await x402.processPayment(req);
|
||||||
|
if (!payResult.allowed && payResult.status === 402) {
|
||||||
|
const headers = { 'Access-Control-Allow-Origin': '*', ...payResult.headers };
|
||||||
|
res.writeHead(402, headers);
|
||||||
|
res.end(payResult.isHtml ? payResult.body : JSON.stringify(payResult.body));
|
||||||
|
return { ok: false };
|
||||||
|
}
|
||||||
|
sendError(res, 401, 'Auth required: X-API-Key header, ?api_key= param, or x402 Payment-Signature header.');
|
||||||
|
return { ok: false };
|
||||||
|
}
|
||||||
|
if (!apiKeys[apiKey]) {
|
||||||
|
sendError(res, 401, 'Invalid API key.');
|
||||||
|
return { ok: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyData = apiKeys[apiKey];
|
||||||
|
resetMonthlyUsage(keyData);
|
||||||
|
if (keyData.used >= keyData.limit) {
|
||||||
|
sendError(res, 429, `Monthly limit reached (${keyData.limit}). Upgrade your plan.`);
|
||||||
|
return { ok: false };
|
||||||
|
}
|
||||||
|
if (!checkRateLimit(apiKey)) {
|
||||||
|
sendError(res, 429, 'Rate limit exceeded. Max 10 requests per minute.');
|
||||||
|
return { ok: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, keyData, settleFn: null };
|
||||||
|
}
|
||||||
|
|
||||||
// Analytics
|
// Analytics
|
||||||
let stats = { total_captures: 0, screenshots: 0, pdfs: 0, md_conversions: 0, errors: 0 };
|
let stats = { total_captures: 0, screenshots: 0, pdfs: 0, md_conversions: 0, errors: 0 };
|
||||||
const ANALYTICS_FILE = path.join(__dirname, 'analytics.json');
|
const ANALYTICS_FILE = path.join(__dirname, 'analytics.json');
|
||||||
@ -279,7 +326,8 @@ const server = http.createServer(async (req, res) => {
|
|||||||
res.writeHead(204, {
|
res.writeHead(204, {
|
||||||
'Access-Control-Allow-Origin': '*',
|
'Access-Control-Allow-Origin': '*',
|
||||||
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
||||||
'Access-Control-Allow-Headers': 'Content-Type, X-API-Key',
|
'Access-Control-Allow-Headers': 'Content-Type, X-API-Key, Payment-Signature, X-Payment',
|
||||||
|
'Access-Control-Expose-Headers': 'Payment-Required, Payment-Response',
|
||||||
});
|
});
|
||||||
res.end();
|
res.end();
|
||||||
return;
|
return;
|
||||||
@ -305,13 +353,27 @@ const server = http.createServer(async (req, res) => {
|
|||||||
if (parsed.pathname === '/api') {
|
if (parsed.pathname === '/api') {
|
||||||
sendJson(res, 200, {
|
sendJson(res, 200, {
|
||||||
service: 'SnapAPI - Document Generation Suite',
|
service: 'SnapAPI - Document Generation Suite',
|
||||||
version: '2.0.0',
|
version: '3.0.0',
|
||||||
description: 'Screenshots, PDFs, and Markdown conversion API',
|
description: 'Screenshots, PDFs, and Markdown conversion API with x402 micropayments',
|
||||||
built_by: 'AI Agent (Claude) - transparent about AI authorship',
|
built_by: 'OpSpawn (AI Agent) - transparent about AI authorship',
|
||||||
|
payment: {
|
||||||
|
x402: {
|
||||||
|
description: 'Pay-per-request via x402 protocol (no signup needed)',
|
||||||
|
network: 'Base (USDC)',
|
||||||
|
prices: x402.PRICES,
|
||||||
|
protocol: 'https://x402.org',
|
||||||
|
how: 'Send Payment-Signature header with signed USDC authorization',
|
||||||
|
},
|
||||||
|
subscription: {
|
||||||
|
description: 'Monthly subscription via USDC on Polygon',
|
||||||
|
endpoint: 'POST /api/subscribe',
|
||||||
|
},
|
||||||
|
},
|
||||||
endpoints: {
|
endpoints: {
|
||||||
'GET /api/capture': {
|
'GET /api/capture': {
|
||||||
description: 'Capture a screenshot or PDF from a URL',
|
description: 'Capture a screenshot or PDF from a URL',
|
||||||
auth: 'X-API-Key header or ?api_key= query param',
|
auth: 'X-API-Key header, ?api_key= query param, OR x402 Payment-Signature header',
|
||||||
|
x402_price: x402.PRICES.capture,
|
||||||
params: {
|
params: {
|
||||||
url: 'Target URL (required)',
|
url: 'Target URL (required)',
|
||||||
format: 'png (default), jpeg, or pdf',
|
format: 'png (default), jpeg, or pdf',
|
||||||
@ -326,12 +388,14 @@ const server = http.createServer(async (req, res) => {
|
|||||||
},
|
},
|
||||||
'POST /api/md2pdf': {
|
'POST /api/md2pdf': {
|
||||||
description: 'Convert Markdown to PDF',
|
description: 'Convert Markdown to PDF',
|
||||||
auth: 'X-API-Key header',
|
auth: 'X-API-Key header OR x402 Payment-Signature header',
|
||||||
|
x402_price: x402.PRICES.md2pdf,
|
||||||
body: 'JSON: { markdown, theme?, paperSize?, landscape?, fontSize?, margins? }',
|
body: 'JSON: { markdown, theme?, paperSize?, landscape?, fontSize?, margins? }',
|
||||||
},
|
},
|
||||||
'POST /api/md2png': {
|
'POST /api/md2png': {
|
||||||
description: 'Convert Markdown to PNG image',
|
description: 'Convert Markdown to PNG image',
|
||||||
auth: 'X-API-Key header',
|
auth: 'X-API-Key header OR x402 Payment-Signature header',
|
||||||
|
x402_price: x402.PRICES.md2png,
|
||||||
body: 'JSON: { markdown, theme?, width?, fontSize? }',
|
body: 'JSON: { markdown, theme?, width?, fontSize? }',
|
||||||
},
|
},
|
||||||
'POST /api/md2html': {
|
'POST /api/md2html': {
|
||||||
@ -369,24 +433,10 @@ const server = http.createServer(async (req, res) => {
|
|||||||
|
|
||||||
// Capture endpoint
|
// Capture endpoint
|
||||||
if (parsed.pathname === '/api/capture') {
|
if (parsed.pathname === '/api/capture') {
|
||||||
// Auth
|
// Auth: x402 micropayment OR API key
|
||||||
const apiKey = req.headers['x-api-key'] || parsed.query.api_key;
|
const auth = await authenticate(req, res, parsed);
|
||||||
if (!apiKey || !apiKeys[apiKey]) {
|
if (!auth.ok) return;
|
||||||
return sendError(res, 401, 'Invalid or missing API key. Include X-API-Key header.');
|
const { keyData, settleFn } = auth;
|
||||||
}
|
|
||||||
|
|
||||||
const keyData = apiKeys[apiKey];
|
|
||||||
resetMonthlyUsage(keyData);
|
|
||||||
|
|
||||||
// Check monthly limit
|
|
||||||
if (keyData.used >= keyData.limit) {
|
|
||||||
return sendError(res, 429, `Monthly limit reached (${keyData.limit} captures). Upgrade your plan.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rate limit
|
|
||||||
if (!checkRateLimit(apiKey)) {
|
|
||||||
return sendError(res, 429, 'Rate limit exceeded. Max 10 requests per minute.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Concurrency limit
|
// Concurrency limit
|
||||||
if (activeTasks >= MAX_CONCURRENT) {
|
if (activeTasks >= MAX_CONCURRENT) {
|
||||||
@ -436,20 +486,34 @@ const server = http.createServer(async (req, res) => {
|
|||||||
userAgent: parsed.query.userAgent,
|
userAgent: parsed.query.userAgent,
|
||||||
});
|
});
|
||||||
|
|
||||||
keyData.used++;
|
if (keyData) {
|
||||||
fs.writeFile(API_KEYS_FILE, JSON.stringify(apiKeys, null, 2), () => {});
|
keyData.used++;
|
||||||
|
fs.writeFile(API_KEYS_FILE, JSON.stringify(apiKeys, null, 2), () => {});
|
||||||
|
}
|
||||||
|
|
||||||
const contentType = parsed.query.format === 'pdf'
|
const contentType = parsed.query.format === 'pdf'
|
||||||
? 'application/pdf'
|
? 'application/pdf'
|
||||||
: parsed.query.format === 'jpeg' ? 'image/jpeg' : 'image/png';
|
: parsed.query.format === 'jpeg' ? 'image/jpeg' : 'image/png';
|
||||||
|
|
||||||
res.writeHead(200, {
|
const responseHeaders = {
|
||||||
'Content-Type': contentType,
|
'Content-Type': contentType,
|
||||||
'Content-Length': buffer.length,
|
'Content-Length': buffer.length,
|
||||||
'X-Captures-Used': keyData.used,
|
|
||||||
'X-Captures-Limit': keyData.limit,
|
|
||||||
'Access-Control-Allow-Origin': '*',
|
'Access-Control-Allow-Origin': '*',
|
||||||
});
|
};
|
||||||
|
if (keyData) {
|
||||||
|
responseHeaders['X-Captures-Used'] = keyData.used;
|
||||||
|
responseHeaders['X-Captures-Limit'] = keyData.limit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settle x402 payment after successful capture
|
||||||
|
if (settleFn) {
|
||||||
|
const settleResult = await settleFn();
|
||||||
|
if (settleResult && settleResult.headers) {
|
||||||
|
Object.assign(responseHeaders, settleResult.headers);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.writeHead(200, responseHeaders);
|
||||||
res.end(buffer);
|
res.end(buffer);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
stats.errors++;
|
stats.errors++;
|
||||||
@ -482,18 +546,10 @@ const server = http.createServer(async (req, res) => {
|
|||||||
|
|
||||||
// Markdown to PDF (auth required - uses Puppeteer)
|
// Markdown to PDF (auth required - uses Puppeteer)
|
||||||
if (parsed.pathname === '/api/md2pdf' && req.method === 'POST') {
|
if (parsed.pathname === '/api/md2pdf' && req.method === 'POST') {
|
||||||
const apiKey = req.headers['x-api-key'] || parsed.query.api_key;
|
const auth = await authenticate(req, res, parsed);
|
||||||
if (!apiKey || !apiKeys[apiKey]) {
|
if (!auth.ok) return;
|
||||||
return sendError(res, 401, 'Invalid or missing API key. Include X-API-Key header.');
|
const { keyData, settleFn } = auth;
|
||||||
}
|
|
||||||
const keyData = apiKeys[apiKey];
|
|
||||||
resetMonthlyUsage(keyData);
|
|
||||||
if (keyData.used >= keyData.limit) {
|
|
||||||
return sendError(res, 429, `Monthly limit reached (${keyData.limit}). Upgrade your plan.`);
|
|
||||||
}
|
|
||||||
if (!checkRateLimit(apiKey)) {
|
|
||||||
return sendError(res, 429, 'Rate limit exceeded. Max 10 requests per minute.');
|
|
||||||
}
|
|
||||||
if (activeTasks >= MAX_CONCURRENT) {
|
if (activeTasks >= MAX_CONCURRENT) {
|
||||||
return sendError(res, 503, 'Server busy. Try again in a few seconds.');
|
return sendError(res, 503, 'Server busy. Try again in a few seconds.');
|
||||||
}
|
}
|
||||||
@ -518,17 +574,30 @@ const server = http.createServer(async (req, res) => {
|
|||||||
marginRight: body.margins?.right,
|
marginRight: body.margins?.right,
|
||||||
});
|
});
|
||||||
|
|
||||||
keyData.used++;
|
if (keyData) {
|
||||||
fs.writeFile(API_KEYS_FILE, JSON.stringify(apiKeys, null, 2), () => {});
|
keyData.used++;
|
||||||
|
fs.writeFile(API_KEYS_FILE, JSON.stringify(apiKeys, null, 2), () => {});
|
||||||
|
}
|
||||||
|
|
||||||
res.writeHead(200, {
|
const responseHeaders = {
|
||||||
'Content-Type': 'application/pdf',
|
'Content-Type': 'application/pdf',
|
||||||
'Content-Length': buffer.length,
|
'Content-Length': buffer.length,
|
||||||
'Content-Disposition': 'inline; filename="document.pdf"',
|
'Content-Disposition': 'inline; filename="document.pdf"',
|
||||||
'X-Captures-Used': keyData.used,
|
|
||||||
'X-Captures-Limit': keyData.limit,
|
|
||||||
'Access-Control-Allow-Origin': '*',
|
'Access-Control-Allow-Origin': '*',
|
||||||
});
|
};
|
||||||
|
if (keyData) {
|
||||||
|
responseHeaders['X-Captures-Used'] = keyData.used;
|
||||||
|
responseHeaders['X-Captures-Limit'] = keyData.limit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settleFn) {
|
||||||
|
const settleResult = await settleFn();
|
||||||
|
if (settleResult && settleResult.headers) {
|
||||||
|
Object.assign(responseHeaders, settleResult.headers);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.writeHead(200, responseHeaders);
|
||||||
res.end(buffer);
|
res.end(buffer);
|
||||||
} finally {
|
} finally {
|
||||||
activeTasks--;
|
activeTasks--;
|
||||||
@ -542,18 +611,10 @@ const server = http.createServer(async (req, res) => {
|
|||||||
|
|
||||||
// Markdown to PNG (auth required - uses Puppeteer)
|
// Markdown to PNG (auth required - uses Puppeteer)
|
||||||
if (parsed.pathname === '/api/md2png' && req.method === 'POST') {
|
if (parsed.pathname === '/api/md2png' && req.method === 'POST') {
|
||||||
const apiKey = req.headers['x-api-key'] || parsed.query.api_key;
|
const auth = await authenticate(req, res, parsed);
|
||||||
if (!apiKey || !apiKeys[apiKey]) {
|
if (!auth.ok) return;
|
||||||
return sendError(res, 401, 'Invalid or missing API key. Include X-API-Key header.');
|
const { keyData, settleFn } = auth;
|
||||||
}
|
|
||||||
const keyData = apiKeys[apiKey];
|
|
||||||
resetMonthlyUsage(keyData);
|
|
||||||
if (keyData.used >= keyData.limit) {
|
|
||||||
return sendError(res, 429, `Monthly limit reached (${keyData.limit}). Upgrade your plan.`);
|
|
||||||
}
|
|
||||||
if (!checkRateLimit(apiKey)) {
|
|
||||||
return sendError(res, 429, 'Rate limit exceeded. Max 10 requests per minute.');
|
|
||||||
}
|
|
||||||
if (activeTasks >= MAX_CONCURRENT) {
|
if (activeTasks >= MAX_CONCURRENT) {
|
||||||
return sendError(res, 503, 'Server busy. Try again in a few seconds.');
|
return sendError(res, 503, 'Server busy. Try again in a few seconds.');
|
||||||
}
|
}
|
||||||
@ -574,18 +635,31 @@ const server = http.createServer(async (req, res) => {
|
|||||||
quality: body.quality,
|
quality: body.quality,
|
||||||
});
|
});
|
||||||
|
|
||||||
keyData.used++;
|
if (keyData) {
|
||||||
fs.writeFile(API_KEYS_FILE, JSON.stringify(apiKeys, null, 2), () => {});
|
keyData.used++;
|
||||||
|
fs.writeFile(API_KEYS_FILE, JSON.stringify(apiKeys, null, 2), () => {});
|
||||||
|
}
|
||||||
|
|
||||||
const imgType = body.format === 'jpeg' ? 'jpeg' : 'png';
|
const imgType = body.format === 'jpeg' ? 'jpeg' : 'png';
|
||||||
res.writeHead(200, {
|
const responseHeaders = {
|
||||||
'Content-Type': `image/${imgType}`,
|
'Content-Type': `image/${imgType}`,
|
||||||
'Content-Length': buffer.length,
|
'Content-Length': buffer.length,
|
||||||
'Content-Disposition': `inline; filename="document.${imgType}"`,
|
'Content-Disposition': `inline; filename="document.${imgType}"`,
|
||||||
'X-Captures-Used': keyData.used,
|
|
||||||
'X-Captures-Limit': keyData.limit,
|
|
||||||
'Access-Control-Allow-Origin': '*',
|
'Access-Control-Allow-Origin': '*',
|
||||||
});
|
};
|
||||||
|
if (keyData) {
|
||||||
|
responseHeaders['X-Captures-Used'] = keyData.used;
|
||||||
|
responseHeaders['X-Captures-Limit'] = keyData.limit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settleFn) {
|
||||||
|
const settleResult = await settleFn();
|
||||||
|
if (settleResult && settleResult.headers) {
|
||||||
|
Object.assign(responseHeaders, settleResult.headers);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.writeHead(200, responseHeaders);
|
||||||
res.end(buffer);
|
res.end(buffer);
|
||||||
} finally {
|
} finally {
|
||||||
activeTasks--;
|
activeTasks--;
|
||||||
@ -698,9 +772,16 @@ const server = http.createServer(async (req, res) => {
|
|||||||
sendError(res, 404, 'Not found. Visit / for API documentation.');
|
sendError(res, 404, 'Not found. Visit / for API documentation.');
|
||||||
});
|
});
|
||||||
|
|
||||||
server.listen(PORT, () => {
|
server.listen(PORT, async () => {
|
||||||
console.log(`Screenshot API running at http://localhost:${PORT}`);
|
console.log(`Screenshot API running at http://localhost:${PORT}`);
|
||||||
console.log(`API keys: ${Object.keys(apiKeys).length} configured`);
|
console.log(`API keys: ${Object.keys(apiKeys).length} configured`);
|
||||||
// Start payment polling (check every 30 seconds)
|
// Start payment polling (check every 30 seconds)
|
||||||
payments.startPolling(30000);
|
payments.startPolling(30000);
|
||||||
|
// Initialize x402 micropayment system
|
||||||
|
try {
|
||||||
|
await x402.initX402();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[x402] Failed to initialize:', err.message);
|
||||||
|
console.log('[x402] API key auth still works. x402 payments disabled.');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
152
x402.js
Normal file
152
x402.js
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
// x402.js - x402 micropayment integration for SnapAPI
|
||||||
|
// Enables pay-per-request payments from AI agents via the x402 protocol
|
||||||
|
|
||||||
|
const { x402HTTPResourceServer, x402ResourceServer, HTTPFacilitatorClient } = require('@x402/core/server');
|
||||||
|
const { ExactEvmScheme } = require('@x402/evm/exact/server');
|
||||||
|
|
||||||
|
const WALLET_ADDRESS = '0x7483a9F237cf8043704D6b17DA31c12BfFF860DD';
|
||||||
|
|
||||||
|
// Pricing per endpoint (in USD, paid in USDC)
|
||||||
|
const PRICES = {
|
||||||
|
capture: '$0.01', // $0.01 per screenshot
|
||||||
|
md2pdf: '$0.005', // $0.005 per markdown-to-PDF
|
||||||
|
md2png: '$0.005', // $0.005 per markdown-to-PNG
|
||||||
|
};
|
||||||
|
|
||||||
|
// Accept x402 micropayments on Base (best EVM support for x402)
|
||||||
|
// Polygon subscription still available via /api/subscribe
|
||||||
|
const NETWORK = 'eip155:8453'; // Base Mainnet
|
||||||
|
|
||||||
|
// PayAI facilitator: supports Base mainnet, no API key required
|
||||||
|
const FACILITATOR_URL = 'https://facilitator.payai.network';
|
||||||
|
|
||||||
|
// HTTP adapter for raw Node.js http.IncomingMessage
|
||||||
|
class NodeHTTPAdapter {
|
||||||
|
constructor(req) {
|
||||||
|
this.req = req;
|
||||||
|
const host = req.headers.host || 'localhost';
|
||||||
|
this.parsedUrl = new URL(req.url || '/', `http://${host}`);
|
||||||
|
}
|
||||||
|
getHeader(name) { return this.req.headers[name.toLowerCase()]; }
|
||||||
|
getMethod() { return this.req.method || 'GET'; }
|
||||||
|
getPath() { return this.parsedUrl.pathname; }
|
||||||
|
getUrl() { return this.parsedUrl.href; }
|
||||||
|
getAcceptHeader() { return this.req.headers.accept || ''; }
|
||||||
|
getUserAgent() { return this.req.headers['user-agent'] || ''; }
|
||||||
|
}
|
||||||
|
|
||||||
|
let httpResourceServer = null;
|
||||||
|
let initialized = false;
|
||||||
|
|
||||||
|
function createRouteConfig(price, description, mimeType) {
|
||||||
|
return {
|
||||||
|
accepts: {
|
||||||
|
scheme: 'exact',
|
||||||
|
network: NETWORK,
|
||||||
|
payTo: WALLET_ADDRESS,
|
||||||
|
price,
|
||||||
|
maxTimeoutSeconds: 120,
|
||||||
|
},
|
||||||
|
description,
|
||||||
|
mimeType,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initX402() {
|
||||||
|
if (initialized) return httpResourceServer;
|
||||||
|
|
||||||
|
const facilitator = new HTTPFacilitatorClient({ url: FACILITATOR_URL });
|
||||||
|
const resourceServer = new x402ResourceServer([facilitator]);
|
||||||
|
|
||||||
|
resourceServer.register(NETWORK, new ExactEvmScheme());
|
||||||
|
|
||||||
|
const routes = {
|
||||||
|
'GET /api/capture': createRouteConfig(
|
||||||
|
PRICES.capture,
|
||||||
|
'Capture screenshot or PDF from a URL',
|
||||||
|
'image/png'
|
||||||
|
),
|
||||||
|
'POST /api/md2pdf': createRouteConfig(
|
||||||
|
PRICES.md2pdf,
|
||||||
|
'Convert Markdown to PDF',
|
||||||
|
'application/pdf'
|
||||||
|
),
|
||||||
|
'POST /api/md2png': createRouteConfig(
|
||||||
|
PRICES.md2png,
|
||||||
|
'Convert Markdown to PNG image',
|
||||||
|
'image/png'
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
httpResourceServer = new x402HTTPResourceServer(resourceServer, routes);
|
||||||
|
await httpResourceServer.initialize();
|
||||||
|
initialized = true;
|
||||||
|
console.log('[x402] Micropayment system initialized (PayAI facilitator)');
|
||||||
|
console.log(`[x402] Accepting USDC on Base to ${WALLET_ADDRESS}`);
|
||||||
|
console.log(`[x402] Prices: capture=${PRICES.capture}, md2pdf=${PRICES.md2pdf}, md2png=${PRICES.md2png}`);
|
||||||
|
return httpResourceServer;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if a request has an x402 payment header
|
||||||
|
function hasX402Payment(req) {
|
||||||
|
return !!(req.headers['payment-signature'] || req.headers['x-payment']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process an x402 payment request
|
||||||
|
// Returns: { allowed: true, settle: Function } or { allowed: false, status, headers, body }
|
||||||
|
async function processPayment(req) {
|
||||||
|
if (!httpResourceServer) {
|
||||||
|
return { allowed: false, status: 503, headers: {}, body: { error: 'x402 not initialized' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const adapter = new NodeHTTPAdapter(req);
|
||||||
|
const context = {
|
||||||
|
adapter,
|
||||||
|
path: adapter.getPath(),
|
||||||
|
method: adapter.getMethod(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await httpResourceServer.processHTTPRequest(context);
|
||||||
|
|
||||||
|
if (result.type === 'no-payment-required') {
|
||||||
|
return { allowed: true, settle: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.type === 'payment-error') {
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
status: result.response.status,
|
||||||
|
headers: result.response.headers || {},
|
||||||
|
body: result.response.body,
|
||||||
|
isHtml: result.response.isHtml,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.type === 'payment-verified') {
|
||||||
|
const settleFn = async () => {
|
||||||
|
try {
|
||||||
|
const settleResult = await httpResourceServer.processSettlement(
|
||||||
|
result.paymentPayload,
|
||||||
|
result.paymentRequirements,
|
||||||
|
result.declaredExtensions
|
||||||
|
);
|
||||||
|
return settleResult;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[x402] Settlement error:', err.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return { allowed: true, settle: settleFn };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { allowed: false, status: 500, headers: {}, body: { error: 'Unknown payment state' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
initX402,
|
||||||
|
hasX402Payment,
|
||||||
|
processPayment,
|
||||||
|
PRICES,
|
||||||
|
WALLET_ADDRESS,
|
||||||
|
NETWORK,
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue
Block a user