x402-perpcheck / tree / src/free-tier.js ยท raw (this view has old GitHub links rewritten for the mirror; raw is byte-exact)
// Free-first tier for the remote MCP endpoint (POST /mcp tools/call). Paid REST routes and their x402 terms are untouched.
// Each client (IPv4 address, or IPv6 /64) gets FREE_DAILY_PER_CLIENT full-result calls per UTC day (default 30); each
// tool also has a global FREE_DAILY_GLOBAL cap (default 5,000) so a traffic spike falls back to the paid path instead
// of burning Workers / upstream budget. The per-minute Rate Limiting binding (FREE_LIMITER) still runs first, so floods
// never reach the counter. Counts live in one SQLite Durable Object per tool (binding FREE_TIER, class FreeTierCounter
// in free-tier-do.js); client keys are stored as sha256(day|ip) prefixes, never raw IPs, and old days are pruned.
// Without the binding (tests, local dev) an in-memory store is used. Storage errors fail open (the minute limiter stays).
export const FREE_TIER_DEFAULTS = { perClient: 30, globalDaily: 5000 };
export const CREDITS_URL = 'https://api.youraitools.co/credits/';

export const utcDay = (t) => new Date(t).toISOString().slice(0, 10);
export const nextResetIso = (t) => new Date(Date.parse(`${utcDay(t)}T00:00:00Z`) + 86400000).toISOString();

function expandV6(ip) {
  const [head, tail = ''] = ip.split('::');
  const h = head ? head.split(':') : []; const t = ip.includes('::') ? (tail ? tail.split(':') : []) : [];
  const fill = ip.includes('::') ? Array(Math.max(0, 8 - h.length - t.length)).fill('0') : [];
  return [...h, ...fill, ...t].map((x) => x.toLowerCase().padStart(4, '0'));
}
/** Client identity for the free tier: IPv4 as-is, IPv6 by /64 (one subscriber line), else 'unknown'. */
export function clientId(c) {
  const raw = (c.req.header('cf-connecting-ip') || c.req.header('x-forwarded-for')?.split(',')[0] || '').trim();
  if (!raw) return 'unknown';
  if (raw.includes(':') && !/^\d+\.\d+\.\d+\.\d+$/.test(raw)) {
    const v4 = raw.match(/::ffff:(\d+\.\d+\.\d+\.\d+)$/i); if (v4) return v4[1];
    try { return `${expandV6(raw).slice(0, 4).join(':')}::/64`; } catch { return raw; }
  }
  return raw;
}
async function hashKey(day, id) {
  const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(`yat-free|${day}|${id}`));
  return [...new Uint8Array(buf)].slice(0, 12).map((b) => b.toString(16).padStart(2, '0')).join('');
}

/** Same contract as FreeTierCounter (free-tier-do.js), in memory. */
export class MemoryFreeTierStore {
  constructor() { this.m = new Map(); this.day = null; }
  take(day, k, perClient, globalDaily) {
    if (day !== this.day) { this.m.clear(); this.day = day; }
    const n = this.m.get(k) || 0; const g = this.m.get('*') || 0;
    if (n >= perClient) return { ok: false, reason: 'client', used: n, global_used: g };
    if (g >= globalDaily) return { ok: false, reason: 'global', used: n, global_used: g };
    this.m.set(k, n + 1); this.m.set('*', g + 1);
    return { ok: true, used: n + 1, global_used: g + 1 };
  }
  refund(day, k) {
    if (day !== this.day) return false;
    const n = this.m.get(k) || 0; const g = this.m.get('*') || 0;
    if (n > 0) this.m.set(k, n - 1); if (g > 0) this.m.set('*', g - 1);
    return true;
  }
}

/** Store backed by the FREE_TIER Durable Object namespace (one instance per tool). */
export function durableFreeTierStore(ns) {
  const stub = () => ns.get(ns.idFromName('free-tier'));
  return { take: (...a) => stub().take(...a), refund: (...a) => stub().refund(...a) };
}

/**
 * @param {{store?: any, perClient?: number, globalDaily?: number, now?: () => number}} opts
 * take(c) -> { ok, reason?, used, remaining, limit, resets_at, failOpen? }; refund(c, t) gives a call back (tool error).
 */
export function createFreeTier({ store, perClient, globalDaily, now = () => Date.now() } = {}) {
  const per = Number(perClient) > 0 ? Math.floor(Number(perClient)) : FREE_TIER_DEFAULTS.perClient;
  const glob = Number(globalDaily) > 0 ? Math.floor(Number(globalDaily)) : FREE_TIER_DEFAULTS.globalDaily;
  const s = store || new MemoryFreeTierStore();
  return {
    perClient: per, globalDaily: glob,
    async take(c) {
      const t = now(); const day = utcDay(t); const base = { limit: per, resets_at: nextResetIso(t) };
      let k;
      try {
        k = await hashKey(day, clientId(c));
        const r = await s.take(day, k, per, glob);
        return { ...base, ...r, remaining: Math.max(0, per - (r.used || 0)), day, k };
      } catch (e) {
        console.warn(`free-tier: store error, failing open: ${e?.message || e}`);
        return { ...base, ok: true, failOpen: true, used: null, remaining: null, day, k };
      }
    },
    async refund(t) { if (!t?.ok || t.failOpen || !t.k) return; try { await s.refund(t.day, t.k); } catch { /* fail-open */ } },
  };
}

/** The extra `upgrade` string on every free MCP result (also the last line of the text output). */
export function upgradeLine(t, { price, url }) {
  const left = t && t.remaining !== null && t.remaining !== undefined ? `${t.remaining}/${t.limit} calls left today` : `${t?.limit ?? FREE_TIER_DEFAULTS.perClient} calls/day`;
  return `Free tier: ${left}. Heavy use? Pay per call in USDC via x402 (${price}/call): ${url}`;
}
/** Free-tier summary object added to structured results. */
export function freeTierInfo(t) {
  return { tier: 'free', limit_per_day: t.limit, used_today: t.used, remaining_today: t.remaining, resets_at: t.resets_at, scope: 'per IP address (IPv6: per /64)' };
}
/** MCP tool result once the free tier is used up: the MCP form of a 402, with the paid x402 URL and terms. */
export function freeTierExhaustedResult(t, { tool, price, url, network = 'eip155:8453', priceNote }) {
  const why = t.reason === 'global'
    ? `${tool}'s free capacity for today is used up (shared daily cap, resets ${t.resets_at}).`
    : `Free tier used up: ${t.used}/${t.limit} calls today from this IP (resets ${t.resets_at}).`;
  const upgrade = `Pay per call in USDC via x402 (${price}/call): ${url}`;
  const text = `${why}\n${upgrade}\nPrepaid credits (one-click wallet button): ${CREDITS_URL}`;
  return {
    isError: true,
    content: [{ type: 'text', text }],
    structuredContent: {
      error: 'payment_required', status: 402, reason: t.reason === 'global' ? 'free_tier_global_cap' : 'free_tier_daily_limit',
      message: why, upgrade,
      free_tier: { limit_per_day: t.limit, used_today: t.used, remaining_today: 0, resets_at: t.resets_at },
      x402: { url, price, asset: 'USDC', network, ...(priceNote ? { note: priceNote } : {}), how: 'Call the URL with an x402 client (e.g. @x402/fetch); the HTTP 402 response carries the PAYMENT-REQUIRED terms. Base, Polygon, Arbitrum One and Solana USDC accepted.' },
      credits: CREDITS_URL,
    },
  };
}

agentgate-source: code mirror on Cloudflare Pages. All repos