rendergate-mainnet / server.js
tantk's picture
fix: createAuthHeaders return shape
4d15eef verified
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);
});
}