// SkateSpots AI proxy — runs on the CVM host (Bun), behind Caddy for TLS. // // This is the same job the Netlify Function `netlify/functions/ai.mjs` does, moved // onto infrastructure we own so a user-facing AI call no longer spends Netlify // compute/bandwidth credits — and so the sats-paid Routstr key lives on our box, // not a third party's. // // • POST /api/ai — chat proxy (curated model allowlist; optional Pro gate) // • POST /api/ai/status — owner-only upstream balance (low-balance nudge) // // The upstream is provider-agnostic. For sats-paid inference it points at a // Routstr node (SASQUATCH_AI_BASE + SASQUATCH_AI_KEY). import { verifyEvent } from 'nostr-tools'; // Bun global (typed minimally so this file needs no bun-types dependency). declare const Bun: { serve(options: { port: number; hostname?: string; fetch: (request: Request) => Response | Promise; }): { stop: () => void }; }; const PORT = Number(process.env.AI_PROXY_PORT || 8787); const HOSTNAME = process.env.AI_PROXY_HOST || '127.0.0.1'; const AI_BASE = ( process.env.SASQUATCH_AI_BASE || process.env.OPENROUTER_BASE_URL || 'https://openrouter.ai/api/v1' ).replace(/\/+$/, ''); const AI_URL = `${AI_BASE}/chat/completions`; const AI_KEY = process.env.SASQUATCH_AI_KEY || process.env.OPENROUTER_API_KEY; const DEFAULT_MODELS = [ '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', ]; const ALLOWED_MODELS = new Set( (process.env.SASQUATCH_MODELS || DEFAULT_MODELS.join(',')) .split(',') .map((s) => s.trim()) .filter(Boolean), ); // ── Pro enforcement ─────────────────────────────────────────────────────────── 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 }, ]; /** Owner-grant fallback (NIP-32 label). Keep in sync with src/lib/sasquatchPro.ts. */ const PRO_LABEL_NAMESPACE = 'org.skatespots.pro'; const PRO_LABEL_VALUE = 'pro'; let providerPubkeyCache: string | null | undefined; async function getProProviderPubkey(): Promise { 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()) as { nostrPubkey?: string }; providerPubkeyCache = j.nostrPubkey || null; } catch { providerPubkeyCache = null; } return providerPubkeyCache; } function decodeB64Json(value: string) { try { return JSON.parse(Buffer.from(value, 'base64').toString('utf8')); } catch { return null; } } function tagVal(tags: string[][] | undefined, name: string): string | undefined { return tags?.find((t) => t[0] === name)?.[1]; } function satsFromBolt11(pr: string): number { 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] as 'm' | 'u' | 'n' | 'p'] ?? 1; return Math.round(n * mult * 1e8); } /** Verify a NIP-98 auth header → pubkey, or null. */ function verifyNip98(authHeader: string | null): string | null { 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; } } /** Tier days for a valid, provider-authored receipt bound to the user. */ function receiptTier( receipt: { pubkey: string; created_at: number; tags: string[][] }, userPubkey: string, providerPubkey: string, ): { at: number; days: number } | null { if (!providerPubkey || receipt.pubkey !== providerPubkey) return null; try { if (!verifyEvent(receipt as never)) return null; } catch { return null; } const desc = tagVal(receipt.tags, 'description'); if (!desc) return null; let zapReq: { pubkey?: string; tags?: string[][] }; try { zapReq = JSON.parse(desc); } catch { return null; } if (!zapReq || zapReq.pubkey !== userPubkey) return null; try { if (!verifyEvent(zapReq as never)) 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 { at: receipt.created_at, days: tier.days }; } /** Expiry from an owner-issued kind-1985 Pro grant, or null. */ function grantExpiry( grant: { kind?: number; pubkey?: string; tags?: string[][] }, userPubkey: string, ): number | null { if (grant?.kind !== 1985 || !grant.pubkey) return null; if (!PLATFORM_OWNER_PUBKEYS.has(grant.pubkey)) return null; try { if (!verifyEvent(grant as never)) return null; } catch { return null; } const tags = grant.tags ?? []; const ns = tags.filter((t) => t[0] === 'L').map((t) => t[1]); if (!ns.includes(PRO_LABEL_NAMESPACE)) return null; const vals = tags.filter((t) => t[0] === 'l').map((t) => t[1]); if (vals.length && !vals.includes(PRO_LABEL_VALUE)) return null; if (!tags.some((t) => t[0] === 'p' && t[1] === userPubkey)) return null; const expiry = Number(tagVal(tags, 'expiry')); return Number.isFinite(expiry) && expiry > 0 ? expiry : null; } /** * Verify a Pro proof (zap receipts + owner grants) → STACKED expiry, or null. * * Accepts both the new `{ receipts, grants }` payload and a bare receipt event * (older installed clients), and adds proofs up with renewal semantics so a * second purchase genuinely extends a pass. */ async function verifyPro(proofHeader: string | null, userPubkey: string): Promise { if (!proofHeader) return null; const payload = decodeB64Json(proofHeader) as | { kind?: number; receipts?: unknown[]; grants?: unknown[] } | null; if (!payload) return null; const receipts = payload.kind === 9735 ? [payload] : Array.isArray(payload.receipts) ? payload.receipts : []; const grants = Array.isArray(payload.grants) ? payload.grants : []; let expiry = 0; const providerPubkey = await getProProviderPubkey(); if (providerPubkey && receipts.length) { const tiers = receipts .map((r) => receiptTier(r as never, userPubkey, providerPubkey)) .filter((t): t is { at: number; days: number } => !!t) .sort((a, b) => a.at - b.at); for (const t of tiers) expiry = Math.max(expiry, t.at) + t.days * DAY_SECONDS; } for (const g of grants) { const e = grantExpiry(g as never, userPubkey); if (e && e > expiry) expiry = e; } return expiry || null; } // 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', ]); function corsHeaders(origin: string): Record { 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, x-sasquatch-auth, x-sasquatch-pro', Vary: 'Origin', }; } function json(body: unknown, status: number, headers: Record): Response { return new Response(JSON.stringify(body), { status, headers: { ...headers, 'Content-Type': 'application/json' }, }); } // Per-IP throttle. A single long-lived process (unlike serverless), so this is a // more meaningful damper than the Netlify version. const WINDOW_MS = 60_000; const LIMIT = 20; const hits = new Map(); async function handleStatus(headers: Record): Promise { if (!AI_KEY) return json({ configured: false }, 200, headers); try { const r = await fetch(`${AI_BASE}/balance/info`, { headers: { Authorization: `Bearer ${AI_KEY}` }, signal: AbortSignal.timeout(8000), }); const j = (await r.json().catch(() => ({}))) as { balance?: number }; const balanceSats = Math.floor(Number(j.balance ?? 0) / 1000); return json({ configured: true, balanceSats, low: balanceSats < 500, provider: AI_BASE }, 200, headers); } catch { return json({ configured: true, balanceSats: null, low: false }, 200, headers); } } async function handleChat(request: Request, headers: Record): Promise { 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 = AI_KEY; if (!key) return json({ error: 'AI proxy not configured' }, 503, headers); const ip = request.headers.get('x-forwarded-for')?.split(',')[0].trim() || request.headers.get('x-real-ip') || '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: { model?: string; messages?: unknown[]; max_tokens?: number; stream?: boolean }; try { body = (await request.json()) as typeof body; } catch { return json({ error: 'Invalid JSON' }, 400, headers); } const requested = String(body.model ?? ''); const model = ALLOWED_MODELS.has(requested) ? requested : ALLOWED_MODELS.values().next().value; 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: Response; try { upstream = await fetch(AI_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); } 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 }); } Bun.serve({ port: PORT, hostname: HOSTNAME, async fetch(request: Request): Promise { const origin = request.headers.get('origin') || ''; const headers = corsHeaders(origin); const path = new URL(request.url).pathname; if (request.method === 'OPTIONS') return new Response(null, { status: 204, headers }); if (request.method !== 'POST') return json({ error: 'Method not allowed' }, 405, headers); if (path === '/api/ai/status') return handleStatus(headers); if (path === '/api/ai') return handleChat(request, headers); return json({ error: 'Not found' }, 404, headers); }, }); console.log(`[ai-proxy] listening on http://${HOSTNAME}:${PORT} (upstream ${AI_BASE})`);