// Netlify Function: POST /api/ai — server-side AI proxy for Sasquatch. // // Why this exists: a `VITE_*` API key is compiled into the client bundle and is // therefore public. This function talks to models server-side (Netlify's AI // Gateway injects the credentials) so no key ever ships to the browser/APK. // // Access: the model list is a curated allowlist so this can't be used as an open // proxy. Set SASQUATCH_REQUIRE_PRO=1 to additionally require a valid NIP-98 auth // header (`x-sasquatch-auth`) plus proof of an active Sasquatch Pro pass // (`x-sasquatch-pro` = a provider-authored zap receipt). Off by default so the // client can be updated before enforcement is switched on. // // Request body (OpenAI chat shape): // { model, messages, max_tokens?, stream? } // When stream:true, the upstream SSE is passed straight through. import { verifyEvent } from 'nostr-tools'; // Netlify's AI Gateway injects OPENROUTER_API_KEY + OPENROUTER_BASE_URL into // Functions (server-side only), so no key is managed or shipped by us. If you // later set your OWN OPENROUTER_API_KEY in the project env, Netlify stops // injecting both, and this falls back to the public OpenRouter endpoint — so it // works either way. const OPENROUTER_BASE = (process.env.OPENROUTER_BASE_URL || 'https://openrouter.ai/api/v1').replace( /\/+$/, '', ); const OPENROUTER_URL = `${OPENROUTER_BASE}/chat/completions`; // Models Sasquatch may request. Curated on purpose: the client sends the model // slug, so an allowlist stops anyone using this endpoint as an open proxy for // arbitrary (potentially expensive) models. These are fast, cheap, capable slugs // served through Netlify's AI Gateway. const ALLOWED_MODELS = new Set([ 'meta-llama/llama-3.3-70b-instruct', 'deepseek/deepseek-chat-v3-0324', 'x-ai/grok-4.5', 'moonshotai/kimi-k2', 'qwen/qwen-2.5-72b-instruct', 'mistralai/mistral-nemo', 'meta-llama/llama-3.1-8b-instruct', ]); // ── Sasquatch Pro enforcement (env-gated) ──────────────────────────────────── const REQUIRE_PRO = process.env.SASQUATCH_REQUIRE_PRO === '1'; const PLATFORM_PRO_ADDRESS = 'skatespots@minibits.cash'; /** Primary platform pubkey — zap requests must tag it; owners always pass. */ const PLATFORM_PUBKEY = '20e7c953439e66f3882ab96fe0faa8ba246d6fb12c03b0cb4e2dc4623b8bcb21'; const PLATFORM_OWNER_PUBKEYS = new Set([ PLATFORM_PUBKEY, 'caba497f7beda0650d6b78ab211216e4d2ebabeec9ca6eb2a05cee8e55c6871b', ]); const DAY_SECONDS = 86_400; /** Tier days ↔ sats. Keep in sync with PRO_TIERS in src/lib/sasquatchPro.ts. */ const PRO_TIERS = [ { days: 7, sats: 2000 }, { days: 30, sats: 5000 }, { days: 90, sats: 12000 }, { days: 365, sats: 36000 }, ]; let providerPubkeyCache; /** Resolve the platform's LNURL provider pubkey (zap receipts must be theirs). */ async function getProProviderPubkey() { if (providerPubkeyCache !== undefined) return providerPubkeyCache; try { const [user, domain] = PLATFORM_PRO_ADDRESS.split('@'); const res = await fetch(`https://${domain}/.well-known/lnurlp/${user}`, { signal: AbortSignal.timeout(6000), }); const j = await res.json(); providerPubkeyCache = j.nostrPubkey || null; } catch { providerPubkeyCache = null; } return providerPubkeyCache; } function decodeB64Json(value) { try { return JSON.parse(Buffer.from(value, 'base64').toString('utf8')); } catch { return null; } } function tagVal(tags, name) { return tags?.find((t) => t[0] === name)?.[1]; } /** Satoshis from a BOLT11 amount. The receipt is provider-signed, so we only * need the amount (not full invoice validation). */ function satsFromBolt11(pr) { const m = /^lnbc(\d+)([munp]?)/i.exec(String(pr).toLowerCase()); if (!m) return 0; const n = Number(m[1]); const mult = { m: 1e-3, u: 1e-6, n: 1e-9, p: 1e-12 }[m[2]] ?? 1; return Math.round(n * mult * 1e8); } /** Verify a NIP-98 auth header → pubkey, or null. A generous window lets one * signature act as a session (so Amber doesn't pop per request). */ function verifyNip98(authHeader) { if (!authHeader) return null; const ev = decodeB64Json(authHeader); if (!ev || ev.kind !== 27235) return null; const method = tagVal(ev.tags, 'method'); if (method && method !== 'POST') return null; const u = tagVal(ev.tags, 'u') ?? ''; if (u && !u.endsWith('/api/ai')) return null; if (Math.abs(Date.now() / 1000 - ev.created_at) > 12 * 3600) return null; try { return verifyEvent(ev) ? ev.pubkey : null; } catch { return null; } } /** Verify a Pro pass (provider-authored zap receipt bound to the caller, paying * the platform ≥ the smallest tier) → proUntil (unix sec), or null. */ async function verifyPro(receiptHeader, userPubkey) { if (!receiptHeader) return null; const receipt = decodeB64Json(receiptHeader); if (!receipt || receipt.kind !== 9735) return null; const providerPubkey = await getProProviderPubkey(); if (!providerPubkey || receipt.pubkey !== providerPubkey) return null; try { if (!verifyEvent(receipt)) return null; } catch { return null; } const desc = tagVal(receipt.tags, 'description'); if (!desc) return null; let zapReq; try { zapReq = JSON.parse(desc); } catch { return null; } if (!zapReq || zapReq.pubkey !== userPubkey) return null; try { if (!verifyEvent(zapReq)) return null; } catch { return null; } if (!zapReq.tags?.some((t) => t[0] === 'p' && t[1] === PLATFORM_PUBKEY)) return null; let paidSats = satsFromBolt11(tagVal(receipt.tags, 'bolt11')); if (!paidSats) { const msat = Number(tagVal(zapReq.tags, 'amount')); if (Number.isFinite(msat) && msat > 0) paidSats = Math.floor(msat / 1000); } const tier = [...PRO_TIERS].sort((a, b) => b.sats - a.sats).find((t) => paidSats >= t.sats); if (!tier) return null; return receipt.created_at + tier.days * DAY_SECONDS; } // Origins allowed to call the proxy (web + local dev + Capacitor native). const ALLOWED_ORIGINS = new Set([ 'https://skate-spots.org', 'http://localhost:8080', 'http://localhost:5173', 'capacitor://localhost', 'http://localhost', 'https://localhost', ]); // Best-effort in-memory throttle. Serverless instances are ephemeral, so this is // a blunt abuse damper, not a hard guarantee. const WINDOW_MS = 60_000; const LIMIT = 20; const hits = new Map(); function corsHeaders(origin) { return { 'Access-Control-Allow-Origin': ALLOWED_ORIGINS.has(origin) ? origin : 'https://skate-spots.org', 'Access-Control-Allow-Methods': 'POST, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type', Vary: 'Origin', }; } function json(body, status, headers) { return new Response(JSON.stringify(body), { status, headers: { ...headers, 'Content-Type': 'application/json' }, }); } export default async function handler(request) { const origin = request.headers.get('origin') || ''; const headers = corsHeaders(origin); if (request.method === 'OPTIONS') return new Response(null, { status: 204, headers }); if (request.method !== 'POST') return json({ error: 'Method not allowed' }, 405, headers); // Pro gate (env-gated): require a NIP-98-signed identity and, unless the // caller is a platform owner, an active Pro pass. if (REQUIRE_PRO) { const pubkey = verifyNip98(request.headers.get('x-sasquatch-auth')); if (!pubkey) return json({ error: 'auth required' }, 401, headers); if (!PLATFORM_OWNER_PUBKEYS.has(pubkey)) { const proUntil = await verifyPro(request.headers.get('x-sasquatch-pro'), pubkey); if (!proUntil || proUntil < Math.floor(Date.now() / 1000)) { return json({ error: 'pro required' }, 402, headers); } } } const key = process.env.OPENROUTER_API_KEY; if (!key) return json({ error: 'AI proxy not configured' }, 503, headers); // Throttle per client IP (Netlify sets x-nf-client-connection-ip). const ip = request.headers.get('x-nf-client-connection-ip') || request.headers.get('x-forwarded-for') || 'unknown'; const now = Date.now(); const recent = (hits.get(ip) ?? []).filter((t) => now - t < WINDOW_MS); if (recent.length >= LIMIT) return json({ error: 'Too many requests' }, 429, headers); recent.push(now); hits.set(ip, recent); let body; try { body = await request.json(); } catch { return json({ error: 'Invalid JSON' }, 400, headers); } const model = String(body.model ?? ''); if (!ALLOWED_MODELS.has(model)) return json({ error: 'Model not allowed' }, 400, headers); const messages = Array.isArray(body.messages) ? body.messages.slice(-20) : []; const maxTokens = Math.min(Number(body.max_tokens) || 400, 800); const stream = body.stream === true; let upstream; try { upstream = await fetch(OPENROUTER_URL, { method: 'POST', headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json', 'HTTP-Referer': 'https://skate-spots.org', 'X-Title': 'SkateSpots', }, body: JSON.stringify({ model, messages, max_tokens: maxTokens, stream }), signal: AbortSignal.timeout(30_000), }); } catch (err) { return json({ error: `Upstream failed: ${String(err)}` }, 502, headers); } // Pass OpenRouter's response straight through (JSON or SSE). const passthrough = { ...headers }; const contentType = upstream.headers.get('content-type'); if (contentType) passthrough['Content-Type'] = contentType; return new Response(upstream.body, { status: upstream.status, headers: passthrough }); } // Netlify Functions v2 routing — serve this at /api/ai. export const config = { path: '/api/ai' };