// Shared helpers for functions/api/*. Platform-neutral apart from the D1 type. import { CardError } from './card.ts' import { RpcError, scrub } from './rpc.ts' export function json(body: unknown, status = 200, cache = 'no-store'): Response { return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': cache, 'X-Content-Type-Options': 'nosniff' }, }) } /** Rejects cross-site browser requests, so other sites can't spend our RPC quota or rate limits. */ export function isSameOrigin(request: Request): boolean { if (request.headers.get('sec-fetch-site') === 'cross-site') return false const origin = request.headers.get('origin') if (!origin) return true try { return new URL(origin).host === new URL(request.url).host } catch { return false } } export const MAX_BODY = 4096 /** The body as text, reading no more than `max` bytes even when no Content-Length was sent. */ async function cappedText(request: Request, max: number): Promise { if (Number(request.headers.get('content-length') ?? '0') > max || !request.body) return null const reader = request.body.getReader() const chunks: Uint8Array[] = [] let size = 0 for (;;) { const { done, value } = await reader.read() if (done) break size += value.byteLength if (size > max) { await reader.cancel() return null } chunks.push(value) } const all = new Uint8Array(size) let at = 0 for (const c of chunks) { all.set(c, at) at += c.byteLength } return new TextDecoder().decode(all) } /** A JSON object body of at most MAX_BODY bytes with exactly the allowed keys, or null. */ export async function readBody(request: Request, keys: readonly string[]): Promise | null> { const text = await cappedText(request, MAX_BODY) if (!text) return null let body: unknown try { body = JSON.parse(text) } catch { return null } if (typeof body !== 'object' || body === null || Array.isArray(body)) return null const got = Object.keys(body) if (got.length !== keys.length || got.some((k) => !keys.includes(k))) return null return body as Record } /** The response for anything a handler throws. Never leaks internals: those go to the log only. */ export function failure(err: unknown, where: string): Response { if (err instanceof CardError) return json({ error: err.message, code: err.code }, err.status) const timedOut = err instanceof Error && (err.name === 'TimeoutError' || err.name === 'AbortError') console.error(`${where} failed:`, scrub(err instanceof RpcError ? `${err.message} ${err.logs.slice(-3).join(' | ')}` : String(err))) return json({ error: timedOut ? "Solana didn't answer in time. Try again." : "Couldn't reach Solana. Try again in a moment.", code: 'unavailable' }, timedOut ? 504 : 502) } // ---------- Rate limiting (same scheme as the site's /api/swap) ---------- /** * A keyed, truncated hash. An unkeyed hash of an IPv4 address can be reversed by trying all 4 * billion of them, so with RATE_SALT set (a Pages secret) it is an HMAC nobody can reverse. */ async function hashed(text: string, salt: string | undefined): Promise { const data = new TextEncoder().encode(text) const digest = salt ? await crypto.subtle.sign('HMAC', await crypto.subtle.importKey('raw', new TextEncoder().encode(salt), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']), data) : await crypto.subtle.digest('SHA-256', data) return [...new Uint8Array(digest).slice(0, 8)].map((b) => b.toString(16).padStart(2, '0')).join('') } /** * Per-minute counter for a scope and client IP, stored as a keyed, truncated * hash with a two-minute expiry; expired rows are swept as requests come in. true = allowed. * Throws if D1 errors; callers refuse (fail closed). */ export async function allow(db: D1Database, scope: string, key: string, perMinute: number, salt?: string): Promise { const minute = Math.floor(Date.now() / 60_000) const bucket = `${scope}:${await hashed(`card:${key}`, salt)}:${minute}` const hits = await db .prepare('INSERT INTO ratelimit (bucket, n, expires) VALUES (?1, 1, ?2) ON CONFLICT(bucket) DO UPDATE SET n = n + 1 RETURNING n') .bind(bucket, minute + 2) .first('n') // Expired rows are swept on about one request in twenty (a write on every request would double // the load on D1 during a rush). if (Math.random() < 0.05) await db.prepare('DELETE FROM ratelimit WHERE expires < ?1').bind(minute).run() return (hits ?? Number.POSITIVE_INFINITY) <= perMinute } /** * The rate-limit key for a client: its IPv4 address, or its IPv6 /64 network (one home or phone * usually gets a whole /64, so counting single IPv6 addresses would let one client rotate freely). */ export function clientIp(request: Request): string { const ip = request.headers.get('CF-Connecting-IP') ?? 'unknown' if (!ip.includes(':')) return ip const [head, tail = ''] = ip.split('::') const left = head ? head.split(':') : [] const right = tail ? tail.split(':') : [] const groups = ip.includes('::') ? [...left, ...Array(8 - left.length - right.length).fill('0'), ...right] : left return `${groups.slice(0, 4).map((g) => g.toLowerCase().replace(/^0+(?=.)/, '')).join(':')}::/64` } /** * A per-isolate, per-minute counter for the high-frequency calls (status polls, resends). It costs * no D1 write (and status polls read nothing from D1 either), so a rush of buyers polling every two * seconds can't saturate the database; it is * approximate across isolates, which is fine for these cheap, read-mostly calls. Put a Cloudflare * WAF rate-limiting rule on the card's own API paths in front of everything as the outer limit (plan * ยง7.2: not the zone's whole /api/*, which the main site uses too). */ const memory = new Map() export const MEMORY_CAP = 10_000 export async function allowInMemory(scope: string, key: string, perMinute: number, salt?: string): Promise { const minute = Math.floor(Date.now() / 60_000) // Same keyed hash as D1, so not even memory holds an IP address. const bucket = `${scope}:${await hashed(`card:${key}`, salt)}` if (memory.size >= MEMORY_CAP) { for (const [k, v] of memory) if (v.minute < minute) memory.delete(k) // Still full inside one minute (a flood of distinct clients): drop the least recently seen, so // memory stays bounded. Their counts restart, which only loosens the limit for them. for (const k of memory.keys()) { if (memory.size < MEMORY_CAP * 0.9) break memory.delete(k) } } const cur = memory.get(bucket) const n = cur && cur.minute === minute ? cur.n + 1 : 1 // Re-inserted so the map's order is least recently seen first. memory.delete(bucket) memory.set(bucket, { minute, n }) return n <= perMinute } export function tooMany(): Response { return new Response(JSON.stringify({ error: 'Too many requests. Wait a minute and try again.', code: 'rate-limited' }), { status: 429, headers: { 'Content-Type': 'application/json; charset=utf-8', 'Retry-After': '60', 'Cache-Control': 'no-store' }, }) }