duly / worker/src/index.js · raw
// Duly edge API. The Worker never touches client or merchant money: it sponsors gas// for calls to our own contract, stores encrypted invoice documents, and sends notices.import { Ledger } from './ledger.js';import { NETWORKS } from '../../web/lib/config.js';import { rpc, viewCall } from '../../web/lib/near.js';import { MAINNET } from '../../evidence/mainnet.js';import { SECURITY_HEADERS, injectApp, invoiceHtml, landingHtml, pageFor } from './pages.js';export { Ledger };const MAX_BODY = 64 * 1024;const FX_TTL_S = 6 * 60 * 60;function json(data, status = 200, extra = {}) {  return new Response(JSON.stringify(data), {    status,    headers: { 'content-type': 'application/json', 'cache-control': 'no-store', ...extra },  });}function netOf(url) {  return url.searchParams.get('net') === 'testnet' ? 'testnet' : 'mainnet';}function ledger(env, net) {  return env.LEDGER.get(env.LEDGER.idFromName(net));}/** Rate limits key on the network, because one IPv6 host often owns a whole /64. */function clientKey(ip) {  return ip.includes(':') ? `${ip.split(':').slice(0, 4).join(':')}/64` : ip;}async function forward(env, net, path, request, ip, search = '') {  const init = { method: request.method, headers: { 'x-duly-ip': clientKey(ip) } };  if (request.method === 'POST' || request.method === 'PUT') {    if (Number(request.headers.get('content-length') || 0) > MAX_BODY) return json({ error: 'Too large' }, 413);    const text = await request.text();    if (text.length > MAX_BODY) return json({ error: 'Too large' }, 413);    init.body = text;    init.headers['content-type'] = 'application/json';  }  return ledger(env, net).fetch(`https://ledger${path}?net=${net}${search}`, init);}async function fx(ctx) {  const key = new Request('https://cache.duly/fx');  const cache = caches.default;  const hit = await cache.match(key);  if (hit) return hit;  const res = await fetch('https://api.frankfurter.dev/v1/latest?base=USD', { signal: AbortSignal.timeout(8000) }).catch(() => null);  if (!res?.ok) return json({ error: 'Exchange rates are unavailable right now' }, 502);  const data = await res.json();  const out = json({ base: 'USD', date: data.date, rates: data.rates, source: 'European Central Bank via frankfurter.dev' }, 200, {    'cache-control': `public, max-age=${FX_TTL_S}`,  });  ctx.waitUntil(cache.put(key, out.clone()));  return out;}const RPC_TIMEOUT_MS = 4000;const LIVE_TTL_S = 60;/** Chain totals for the landing page, cached for a minute so page views cost no RPC calls. */async function liveTotals(ctx) {  const key = new Request('https://cache.duly/live-totals');  const hit = await caches.default.match(key);  if (hit) return hit.json();  const net = NETWORKS.mainnet;  const opts = { timeoutMs: RPC_TIMEOUT_MS };  const [totals, held] = await Promise.all([    viewCall(net.rpc, net.contract, 'get_totals', {}, opts),    viewCall(net.rpc, net.usdc, 'ft_balance_of', { account_id: net.contract }, opts),  ]);  const live = { at: Date.now(), invoices: String(totals.invoices), received: String(totals.received), liabilities: String(totals.liabilities), held: String(held) };  ctx.waitUntil(caches.default.put(key, new Response(JSON.stringify(live), { headers: { 'cache-control': `public, max-age=${LIVE_TTL_S}` } })));  return live;}/** One invoice as the chain holds it, plus the settlement records the watcher kept. */async function invoiceFacts(env, page) {  const net = NETWORKS[page.test ? 'testnet' : 'mainnet'];  const opts = { timeoutMs: RPC_TIMEOUT_MS };  let inv;  try {    inv = await viewCall(net.rpc, net.contract, 'get_invoice', { id: page.id }, opts);  } catch {    return { inv: undefined };  }  if (!inv) return { inv: null };  const [payments, verified] = await Promise.all([    BigInt(inv.paid) > 0n      ? ledger(env, net.id).fetch(`https://ledger/watch/get?net=${net.id}&id=${encodeURIComponent(page.id)}`).then((r) => r.json()).then((j) => j.payments || []).catch(() => [])      : [],    net.codeHash      ? rpc(net.rpc, 'query', { request_type: 'view_account', finality: 'final', account_id: net.contract }, opts).then((a) => a.code_hash === net.codeHash).catch(() => null)      : null,  ]);  return { inv, payments, verified };}/** index.html with the server-rendered page in its app root. Falls back to the plain file. */async function renderPage(request, env, ctx, page) {  const shell = await env.ASSETS.fetch(new Request(new URL('/', request.url)));  if (!shell.ok) return shell;  const html = await shell.text();  let inner;  let meta;  if (page.kind === 'landing') {    const live = await liveTotals(ctx).catch(() => null);    inner = landingHtml({ test: page.test, live });    meta = { title: page.test ? 'Duly test mode' : 'Duly', description: `Pay-by-link invoicing settled on NEAR: the payment closes the invoice on-chain and sets tax aside in the same step. Mainnet contract ${MAINNET.contract}.` };  } else {    const facts = await invoiceFacts(env, page);    inner = invoiceHtml({ id: page.id, test: page.test, ...facts });    const status = facts.inv ? `${facts.inv.status}, ${Number(facts.inv.amount) / 1e6} USDC` : 'not available';    meta = { title: `Duly invoice ${page.id}`, description: `Invoice ${page.id} on contract ${page.test ? 'dulypay.testnet' : MAINNET.contract}: ${status}.` };  }  const body = injectApp(html, inner, meta);  return new Response(request.method === 'HEAD' ? null : body, {    headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': page.kind === 'landing' ? 'public, max-age=60' : 'no-cache', ...SECURITY_HEADERS },  });}export default {  async fetch(request, env, ctx) {    const url = new URL(request.url);    const { pathname } = url;    if (!pathname.startsWith('/api/')) {      const page = (request.method === 'GET' || request.method === 'HEAD') && pageFor(pathname);      if (page) {        try {          return await renderPage(request, env, ctx, page);        } catch (e) {          console.error('render failed', e?.message);        }      }      return env.ASSETS.fetch(request);    }    const ip = request.headers.get('cf-connecting-ip') || 'unknown';    const net = netOf(url);    const post = request.method === 'POST';    if (post) {      const type = request.headers.get('content-type') || '';      if (!type.startsWith('application/json')) return json({ error: 'Send JSON' }, 415);    }    if (pathname === '/api/relay' && post) return forward(env, net, '/relay', request, ip);    if (pathname === '/api/onboard' && post) return forward(env, net, '/onboard', request, ip);    if (pathname === '/api/doc' && post) return forward(env, net, '/doc/put', request, ip);    if (pathname === '/api/doc' && request.method === 'GET') {      return forward(env, net, '/doc/get', request, ip, `&id=${encodeURIComponent(url.searchParams.get('id') || '')}`);    }    if (pathname === '/api/watch' && post) return forward(env, net, '/watch', request, ip);    if (pathname === '/api/watch' && request.method === 'GET') {      return forward(env, net, '/watch/get', request, ip, `&id=${encodeURIComponent(url.searchParams.get('id') || '')}`);    }    if (pathname === '/api/test/pay' && post) return forward(env, 'testnet', '/test/pay', request, ip);    if (pathname === '/api/telegram/link' && post) return forward(env, net, '/tg/code', request, ip);    if (pathname === '/api/telegram/status' && request.method === 'GET') {      return forward(env, net, '/tg/status', request, ip, `&merchant=${encodeURIComponent(url.searchParams.get('merchant') || '')}`);    }    if (pathname === '/api/agent/rules' && post) return forward(env, net, '/agent/rules', request, ip);    if (pathname === '/api/fx' && request.method === 'GET') return fx(ctx);    if (pathname === '/api/stats' && request.method === 'GET') return forward(env, net, '/stats', request, ip);    if (pathname === '/api/telegram/webhook' && post) {      const given = request.headers.get('x-telegram-bot-api-secret-token') || '';      if (!env.TELEGRAM_WEBHOOK_SECRET || given !== env.TELEGRAM_WEBHOOK_SECRET) {        return json({ error: 'Forbidden' }, 403);      }      // One webhook serves both networks: a link's code says which ledger it belongs to.      const body = await request.text();      if (body.length > MAX_BODY) return json({ ok: true });      let text = '';      try {        text = String(JSON.parse(body)?.message?.text ?? '');      } catch {        return json({ ok: true });      }      const nets = /^\/stop\b/.test(text) ? ['mainnet', 'testnet'] : [/^\/start\s+t/.test(text) ? 'testnet' : 'mainnet'];      for (const n of nets) {        await ledger(env, n).fetch(`https://ledger/tg/update?net=${n}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body });      }      return json({ ok: true });    }    return json({ error: 'Not found' }, 404);  },  async scheduled(_event, env, ctx) {    for (const net of ['mainnet', 'testnet']) {      ctx.waitUntil(ledger(env, net).fetch(`https://ledger/tick?net=${net}`).catch(() => null));    }  },};