File size: 5,959 Bytes
dcf8b6b
 
 
 
 
 
 
 
4ac478a
 
 
 
 
 
dcf8b6b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4ac478a
 
 
4d15eef
 
 
 
 
 
4ac478a
 
dcf8b6b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
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);
  });
}