Spaces:
Running
Running
| import "dotenv/config"; | |
| import express from "express"; | |
| import { paymentMiddlewareFromConfig } from "@x402/express"; | |
| import { HTTPFacilitatorClient } from "@x402/core/server"; | |
| import { ExactStellarScheme } from "@x402/stellar/exact/server"; | |
| import { Transaction, Address } from "@stellar/stellar-sdk"; | |
| import { renderUrl, closeBrowser } from "./renderer.js"; | |
| import { isFailedRender, sendRefund } from "./refund.js"; | |
| import { | |
| NETWORK, | |
| NETWORK_PASSPHRASE, | |
| FACILITATOR_URL, | |
| FACILITATOR_API_KEY, | |
| } from "./network-config.js"; | |
| import { isAllowedUrl } from "./url-policy.js"; | |
| const PORT = process.env.PORT || 3001; | |
| const PRICE = "$0.001"; | |
| const REFUND_AMOUNT = "0.001"; | |
| const PAY_TO = process.env.PAY_TO; | |
| if (!PAY_TO) { | |
| console.error("ERROR: PAY_TO not set in .env"); | |
| process.exit(1); | |
| } | |
| // Extract payer from the payment-signature request header. | |
| // x402 payments are exactly one InvokeHostFunction op calling USDC.transfer | |
| // with one auth entry whose address is the payer. Reject anything that doesn't | |
| // match this exact shape β we don't want to refund ambiguous transactions. | |
| function getPayerAddress(req) { | |
| try { | |
| const header = req.get("payment-signature") || req.get("x-payment"); | |
| if (!header) return null; | |
| const decoded = JSON.parse(Buffer.from(header, "base64").toString()); | |
| const txXdr = decoded?.payload?.transaction; | |
| if (!txXdr) return null; | |
| const tx = new Transaction(txXdr, NETWORK_PASSPHRASE); | |
| if (tx.operations.length !== 1) return null; | |
| const op = tx.operations[0]; | |
| if (op.type !== "invokeHostFunction") return null; | |
| if (!op.auth || op.auth.length !== 1) return null; | |
| const creds = op.auth[0].credentials(); | |
| if (creds.switch().name !== "sorobanCredentialsAddress") return null; | |
| return Address.fromScAddress(creds.address().address()).toString(); | |
| } catch { | |
| return null; | |
| } | |
| } | |
| const app = express(); | |
| // Info endpoint (free) | |
| app.get("/", (_, res) => | |
| res.json({ | |
| service: "RenderGate", | |
| description: "Pay-per-render headless browser API on Stellar x402", | |
| price: PRICE, | |
| network: NETWORK, | |
| usage: "GET /render?url=<encoded_url>", | |
| }), | |
| ); | |
| // Health check (free) | |
| app.get("/health", (_, res) => res.json({ status: "ok" })); | |
| // URL validation β runs before payment to reject SSRF attempts early | |
| app.use("/render", (req, res, next) => { | |
| const url = req.query.url; | |
| if (!url) return res.status(400).json({ error: "Missing ?url= parameter" }); | |
| let decoded; | |
| try { | |
| decoded = decodeURIComponent(url); | |
| } catch { | |
| return res.status(400).json({ error: "Malformed URL encoding" }); | |
| } | |
| if (!isAllowedUrl(decoded)) { | |
| return res | |
| .status(400) | |
| .json({ error: "URL not allowed β only public http/https URLs" }); | |
| } | |
| req.decodedUrl = decoded; | |
| next(); | |
| }); | |
| // x402 payment middleware β protects /render | |
| app.use( | |
| paymentMiddlewareFromConfig( | |
| { | |
| "GET /render": { | |
| accepts: { | |
| scheme: "exact", | |
| price: PRICE, | |
| network: NETWORK, | |
| payTo: PAY_TO, | |
| maxTimeoutSeconds: 60, | |
| }, | |
| description: "Render a JS-heavy webpage and return extracted content", | |
| }, | |
| }, | |
| new HTTPFacilitatorClient({ | |
| url: FACILITATOR_URL, | |
| ...(FACILITATOR_API_KEY && { | |
| // The lib calls this for each operation ("verify"/"settle"/"supported") | |
| // and indexes into the returned object by op name. | |
| createAuthHeaders: async () => { | |
| const auth = { Authorization: `Bearer ${FACILITATOR_API_KEY}` }; | |
| return { verify: auth, settle: auth, supported: auth }; | |
| }, | |
| }), | |
| }), | |
| [{ network: NETWORK, server: new ExactStellarScheme() }], | |
| ), | |
| ); | |
| // Protected render endpoint | |
| app.get("/render", async (req, res) => { | |
| const decoded = req.decodedUrl; | |
| try { | |
| console.log(`Rendering: ${decoded}`); | |
| const start = Date.now(); | |
| const result = await renderUrl(decoded); | |
| const elapsed = Date.now() - start; | |
| const failReason = isFailedRender(result.content, result.title); | |
| if (failReason) { | |
| const payerAddress = getPayerAddress(req); | |
| let refund = null; | |
| if (payerAddress) { | |
| console.log(`Bad render (${failReason}) for ${decoded} β refunding ${payerAddress}`); | |
| const refundHash = await sendRefund(payerAddress, REFUND_AMOUNT, `refund:${failReason}`); | |
| refund = refundHash | |
| ? { transaction: refundHash, amount: `${REFUND_AMOUNT} USDC`, reason: failReason } | |
| : { error: "Refund failed β contact support", reason: failReason }; | |
| } | |
| return res.json({ | |
| ...result, | |
| renderTimeMs: elapsed, | |
| payment: { price: PRICE, network: NETWORK }, | |
| refund, | |
| }); | |
| } | |
| res.json({ | |
| ...result, | |
| renderTimeMs: elapsed, | |
| payment: { price: PRICE, network: NETWORK }, | |
| }); | |
| } catch (err) { | |
| console.error(`Render failed for ${decoded}:`, err.message); | |
| if (err.message.includes("Too many concurrent")) { | |
| return res.status(503).json({ error: err.message }); | |
| } | |
| // Attempt refund on crash | |
| const payerAddress = getPayerAddress(req); | |
| let refund = null; | |
| if (payerAddress) { | |
| const refundHash = await sendRefund(payerAddress, REFUND_AMOUNT, "refund:render_crash"); | |
| refund = refundHash | |
| ? { transaction: refundHash, amount: `${REFUND_AMOUNT} USDC`, reason: "render_crash" } | |
| : { error: "Refund failed β contact support" }; | |
| } | |
| res.status(500).json({ error: "Render failed", message: err.message, refund }); | |
| } | |
| }); | |
| const server = app.listen(Number(PORT), () => { | |
| console.log(`RenderGate listening on http://localhost:${PORT}`); | |
| console.log(` Pay ${PRICE} USDC on ${NETWORK} per render`); | |
| console.log(` Payments go to ${PAY_TO}`); | |
| }); | |
| for (const sig of ["SIGTERM", "SIGINT"]) { | |
| process.on(sig, async () => { | |
| console.log(`${sig} received, shutting down...`); | |
| server.close(); | |
| await closeBrowser(); | |
| process.exit(0); | |
| }); | |
| } | |