SkateSpots/netlify/functions/ai.mjs

349 lines
13 KiB
JavaScript

// 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';
// The upstream is provider-agnostic (any OpenAI-compatible endpoint):
// • default: Netlify's AI Gateway (it injects OPENROUTER_API_KEY +
// OPENROUTER_BASE_URL server-side, so no key is managed or shipped);
// • or set your own key: SASQUATCH_AI_BASE + SASQUATCH_AI_KEY (overrides).
// This is how you switch to a sats-only provider (e.g. Routstr:
// SASQUATCH_AI_BASE=https://api.routstr.com/v1, SASQUATCH_AI_KEY=cashuA… or
// sk-…) without touching code — just remember the model list must match.
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;
// 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. Override with SASQUATCH_MODELS
// (comma-separated) when switching provider — it must match PROXY_MODELS in
// sasquatchBrain.ts.
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),
);
// ── 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 },
];
/** 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;
/** 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;
}
}
/** Tier days for a valid, provider-authored receipt bound to the user. */
function receiptTier(receipt, userPubkey, providerPubkey) {
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 { at: receipt.created_at, days: tier.days };
}
/** Expiry from an owner-issued kind-1985 Pro grant, or null. */
function grantExpiry(grant, userPubkey) {
if (grant?.kind !== 1985 || !grant.pubkey) return null;
if (!PLATFORM_OWNER_PUBKEYS.has(grant.pubkey)) return null;
try {
if (!verifyEvent(grant)) 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.includes(PRO_LABEL_VALUE)) return null; // require the value tag, like the client
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 the new `{ receipts, grants }` payload and a bare receipt (old clients).
*/
async function verifyPro(proofHeader, userPubkey) {
if (!proofHeader) return null;
const payload = decodeB64Json(proofHeader);
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, userPubkey, providerPubkey))
.filter(Boolean)
.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, 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',
]);
// 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' },
});
}
/**
* Owner-only: report the upstream (Routstr) account balance, so the app can nudge
* the operator to top up before AI degrades to the keyless fallback. This is a
* simpler "low balance" signal than wiring a Routstr webhook, which would need a
* notification channel (a signing key) we don't otherwise have server-side.
*/
async function handleStatus(_request, headers) {
// Public on purpose: this is just a small prepaid balance, and requiring a
// NIP-98 signature would make the signer pop up merely to display a number.
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(() => ({}));
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);
}
}
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);
// Owner-only balance status (for the low-balance nudge).
if (new URL(request.url).pathname === '/api/ai/status') return handleStatus(request, 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 = AI_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);
}
// The server is authoritative on models: if the client asks for a slug we
// don't allow, use our configured default instead of erroring. That makes a
// provider switch a server-env change (SASQUATCH_MODELS) with no client
// redeploy needed — the client's list is just a preference.
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;
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);
}
// Pass the upstream 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 the proxy plus its status endpoint.
export const config = { path: ['/api/ai', '/api/ai/status'] };