From ccc04cc172048259935eeeb558c369cf40c78a4a Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Sun, 27 Sep 2026 14:18:33 +0700 Subject: [PATCH] feat(sasquatch-pro): server-side Pro gate on the AI proxy + client credentials MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - netlify/functions/ai.mjs: optional Pro enforcement (SASQUATCH_REQUIRE_PRO=1). Requires a NIP-98 kind-27235 auth header (x-sasquatch-auth) and, unless the caller is a platform owner, an active Pro pass (x-sasquatch-pro = a provider-authored zap receipt) verified OFFLINE — signature, user binding, platform tag, and paid-amount → tier. Off by default. - src/lib/proAuth.ts: signs the NIP-98 once per session (no Amber popup per message) and attaches the Pro receipt. - sasquatchBrain: threads auth headers through the proxy (streaming + non). - useSasquatchPro: exposes the winning receipt event. - Verified: 200 with the gate off, 401 with it on (env reset afterwards). --- AGENTS.md | 7 ++ netlify/functions/ai.mjs | 141 ++++++++++++++++++++++++++++-- src/components/SasquatchGuide.tsx | 16 +++- src/hooks/useSasquatchPro.ts | 25 +++++- src/lib/proAuth.ts | 55 ++++++++++++ src/lib/sasquatchBrain.ts | 21 +++-- 6 files changed, 246 insertions(+), 19 deletions(-) create mode 100644 src/lib/proAuth.ts diff --git a/AGENTS.md b/AGENTS.md index 0587423..8043efd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -112,6 +112,13 @@ session with **no other memory of this project**. So: Retention, so gemini/anthropic/gpt-4o-mini are **not** routable) — keep `ALLOWED_MODELS` in the function in sync with `PROXY_MODELS` in `sasquatchBrain.ts`. Keyless **Pollinations** is the fallback. + The proxy has an **env-gated Pro paywall**: set `SASQUATCH_REQUIRE_PRO=1` and + it requires `x-sasquatch-auth` (a NIP-98 kind-27235 event) plus + `x-sasquatch-pro` (the Pro zap receipt, verified offline incl. paid-amount→ + tier); platform owners bypass. Off by default. The client (`src/lib/proAuth.ts`) + signs NIP-98 once per session and attaches the receipt — **but the per-crew + agent (`useCrewAgentChat`) does not yet**, so don't enable the gate until it + does. The pay-to address is single-sourced in `src/lib/platform.ts`. **AI answers are Pro-only**: free users get the static FAQ/glossary + local relay lookups only (`if (isPro)` gates the AI block in `SasquatchGuide`). Pro access (`useSasquatchPro`) is granted by a provider-authored zap receipt, diff --git a/netlify/functions/ai.mjs b/netlify/functions/ai.mjs index 5b6afc5..d60515d 100644 --- a/netlify/functions/ai.mjs +++ b/netlify/functions/ai.mjs @@ -1,16 +1,19 @@ // 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 keeps the OpenRouter key server-side (Netlify -// env var OPENROUTER_API_KEY) so it never ships to the browser/APK. +// 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. // -// Safety: only `:free` OpenRouter models are allowed, so even an abused endpoint -// cannot spend credits — the worst case is exhausting the free quota (the app -// then falls back to the keyless Pollinations provider). +// 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, OpenRouter's SSE is passed straight through. +// 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 @@ -37,6 +40,119 @@ const ALLOWED_MODELS = new Set([ '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', @@ -76,6 +192,19 @@ export default async function handler(request) { 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); diff --git a/src/components/SasquatchGuide.tsx b/src/components/SasquatchGuide.tsx index 89aadb6..dd8f1fc 100644 --- a/src/components/SasquatchGuide.tsx +++ b/src/components/SasquatchGuide.tsx @@ -21,6 +21,7 @@ import { SasquatchProDialog } from '@/components/SasquatchProDialog'; import { useSasquatchActions, type SasquatchDraft } from '@/hooks/useSasquatchActions'; import { SasquatchActionCard } from '@/components/SasquatchActionCard'; import { callSasquatchAI, streamSasquatchAI } from '@/lib/sasquatchBrain'; +import { buildProAuthHeaders } from '@/lib/proAuth'; import { answerFromGlossary } from '@/lib/glossary'; import { TripItineraryCard } from '@/components/TripItineraryCard'; import { buildItinerary, type Itinerary, type PlannerSpot, type PlannerEvent, type PlannerCoach, type PlannerCrew, type WeatherLite } from '@/lib/tripPlanner'; @@ -634,7 +635,7 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) { const { user } = useCurrentUser(); const cvm = useCVMClient(); const personal = usePersonalContext(); - const { isPro } = useSasquatchPro(); + const { isPro, receiptEvent } = useSasquatchPro(); const { publishDraft } = useSasquatchActions(); const [proDialogOpen, setProDialogOpen] = useState(false); @@ -964,6 +965,14 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) { // AI answers are a Pro feature. Free users skip this entirely and fall // through to the static FAQ, glossary and local relay lookups below. if (isPro) try { + // Prove identity + Pro pass to the proxy when its gate is enabled. + // Signed once per session (see buildProAuthHeaders) so Amber doesn't pop + // on every message. + const authHeaders = await buildProAuthHeaders({ + pubkey: user?.pubkey, + signEvent: user ? (e) => user.signer.signEvent(e) : undefined, + receiptEvent, + }); // The system prompt goes in a real `system` message rather than being // smuggled into a user turn — the model then treats the relay/derived // text below as lower-trust input, blunting prompt-injection via event @@ -981,6 +990,7 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) { // Streamed so the answer appears as it's written; raw JSON tool calls are // suppressed so partial JSON never shows up as text. const aiText = await streamAI(historyWithContext, 400, { + authHeaders, onDelta: (cumulative) => { if (streamSuppressed) return; if (cumulative.trimStart().startsWith('{')) { @@ -1214,7 +1224,7 @@ Instructions: - Keep it short and skater-friendly - Never make up data not in the results above`, }, - ]); + ], 400, undefined, authHeaders); } } catch (toolErr) { setCvmConnected(false); @@ -1359,7 +1369,7 @@ Instructions: setIsThinking(false); setStreamingText(''); } - }, [userInput, isThinking, chatMessages, cvm, personal, isPro, appendMessage, runTripPlanner]); + }, [userInput, isThinking, chatMessages, cvm, personal, isPro, receiptEvent, user, appendMessage, runTripPlanner]); // Listen for external events from FAQ/About pages useEffect(() => { diff --git a/src/hooks/useSasquatchPro.ts b/src/hooks/useSasquatchPro.ts index d1dbe04..2bd067f 100644 --- a/src/hooks/useSasquatchPro.ts +++ b/src/hooks/useSasquatchPro.ts @@ -46,6 +46,8 @@ export interface ProStatus { /** How access was granted. */ source: 'owner' | 'zap' | null; isLoading: boolean; + /** The zap receipt that granted access — sent to the AI proxy as proof. */ + receiptEvent?: NostrEvent | null; } function tagVal(tags: string[][], name: string): string | undefined { @@ -124,7 +126,13 @@ export function useSasquatchPro(): ProStatus { queryKey: ['sasquatch-pro', pubkey], enabled: !!pubkey && !isOwner, staleTime: 60_000, - queryFn: async (c): Promise<{ expiresAt: number | null; source: ProStatus['source'] }> => { + queryFn: async ( + c, + ): Promise<{ + expiresAt: number | null; + source: ProStatus['source']; + receipt?: NostrEvent; + }> => { if (!pubkey) return { expiresAt: null, source: null }; const signal = AbortSignal.any([c.signal, AbortSignal.timeout(6000)]); @@ -143,18 +151,26 @@ export function useSasquatchPro(): ProStatus { ); let best = 0; + let bestReceipt: NostrEvent | undefined; for (const ev of events) { const exp = expiryFromZapReceipt(ev, pubkey, providerPubkey); - if (exp && exp > best) best = exp; + if (exp && exp > best) { + best = exp; + bestReceipt = ev; + } } - return { expiresAt: best || null, source: best ? 'zap' : null }; + return { + expiresAt: best || null, + source: best ? ('zap' as const) : null, + receipt: bestReceipt, + }; }, }); return useMemo(() => { if (isOwner) { - return { isPro: true, expiresAt: null, source: 'owner', isLoading: false }; + return { isPro: true, expiresAt: null, source: 'owner', isLoading: false, receiptEvent: null }; } const now = Math.floor(Date.now() / 1000); const expiresAt = data?.expiresAt ?? null; @@ -164,6 +180,7 @@ export function useSasquatchPro(): ProStatus { expiresAt, source: isPro ? data?.source ?? null : null, isLoading, + receiptEvent: isPro ? data?.receipt ?? null : null, }; }, [isOwner, data, isLoading]); } diff --git a/src/lib/proAuth.ts b/src/lib/proAuth.ts new file mode 100644 index 0000000..cdf78ca --- /dev/null +++ b/src/lib/proAuth.ts @@ -0,0 +1,55 @@ +/** + * Pro auth for the AI proxy. + * + * When the proxy's Pro gate is enabled (`SASQUATCH_REQUIRE_PRO=1`) every + * `/api/ai` request must carry: + * • `x-sasquatch-auth` — a base64 NIP-98 (kind 27235) event proving the caller; + * • `x-sasquatch-pro` — base64 of the Pro zap receipt proving an active pass. + * + * The NIP-98 event is signed ONCE per session and reused, so an interactive + * signer (Amber) doesn't pop on every message. + */ +import type { NostrEvent } from '@nostrify/nostrify'; + +type SignEvent = (event: { + kind: number; + created_at: number; + tags: string[][]; + content: string; +}) => Promise; + +let cachedAuthHeader: string | null = null; +let cachedAuthPubkey: string | null = null; + +function toBase64(value: string): string { + // Event JSON is ASCII (hex pubkeys/ids, tags), so btoa is safe here. + return btoa(value); +} + +export async function buildProAuthHeaders(opts: { + pubkey?: string; + signEvent?: SignEvent; + receiptEvent?: NostrEvent | null; +}): Promise> { + const { pubkey, signEvent, receiptEvent } = opts; + const headers: Record = {}; + if (!pubkey || !signEvent) return headers; + + if (cachedAuthPubkey !== pubkey || !cachedAuthHeader) { + const event = await signEvent({ + kind: 27235, + created_at: Math.floor(Date.now() / 1000), + tags: [ + ['u', 'https://skate-spots.org/api/ai'], + ['method', 'POST'], + ], + content: '', + }); + cachedAuthHeader = toBase64(JSON.stringify(event)); + cachedAuthPubkey = pubkey; + } + + headers['x-sasquatch-auth'] = cachedAuthHeader; + if (receiptEvent) headers['x-sasquatch-pro'] = toBase64(JSON.stringify(receiptEvent)); + return headers; +} diff --git a/src/lib/sasquatchBrain.ts b/src/lib/sasquatchBrain.ts index 2712a79..bb6b7b7 100644 --- a/src/lib/sasquatchBrain.ts +++ b/src/lib/sasquatchBrain.ts @@ -64,6 +64,7 @@ async function tryProxy( messages: ChatTurn[], maxTokens: number, remainingMs: () => number, + authHeaders: Record = {}, ): Promise { const models = PROXY_MODELS.slice(0, MAX_PROXY_MODELS); for (const model of models) { @@ -76,7 +77,7 @@ async function tryProxy( if (left <= 1000) return ''; const response = await fetch(AI_PROXY_URL, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', ...authHeaders }, body: JSON.stringify({ model, messages, max_tokens: maxTokens }), signal: AbortSignal.timeout(Math.min(PER_REQUEST_TIMEOUT_MS, left)), }); @@ -152,11 +153,12 @@ export async function callSasquatchAI( messages: ChatTurn[], maxTokens = 400, totalTimeoutMs = TOTAL_TIMEOUT_MS, + authHeaders: Record = {}, ): Promise { const deadline = Date.now() + totalTimeoutMs; const remainingMs = () => deadline - Date.now(); - const primary = await tryProxy(messages, maxTokens, remainingMs); + const primary = await tryProxy(messages, maxTokens, remainingMs, authHeaders); if (primary) return primary; console.warn('[Sasquatch] AI proxy exhausted — trying backup provider'); @@ -240,6 +242,7 @@ async function streamProxy( maxTokens: number, onDelta: ((cumulative: string) => void) | undefined, remainingMs: () => number, + authHeaders: Record = {}, ): Promise { const models = PROXY_MODELS.slice(0, MAX_PROXY_MODELS); for (const model of models) { @@ -247,7 +250,7 @@ async function streamProxy( try { const attempt = await streamOpenAiCompatible( AI_PROXY_URL, - {}, + authHeaders, { model, messages, max_tokens: maxTokens }, onDelta, remainingMs, @@ -294,12 +297,18 @@ async function streamPollinations( export async function streamSasquatchAI( messages: ChatTurn[], maxTokens = 400, - handlers: { onDelta?: (cumulative: string) => void } = {}, + handlers: { onDelta?: (cumulative: string) => void; authHeaders?: Record } = {}, ): Promise { const deadline = Date.now() + TOTAL_TIMEOUT_MS; const remainingMs = () => deadline - Date.now(); - const primary = await streamProxy(messages, maxTokens, handlers.onDelta, remainingMs); + const primary = await streamProxy( + messages, + maxTokens, + handlers.onDelta, + remainingMs, + handlers.authHeaders, + ); if (primary.text) return primary.text; const backup = await streamPollinations(messages, maxTokens, handlers.onDelta, remainingMs); @@ -309,7 +318,7 @@ export async function streamSasquatchAI( if (primary.delivered || backup.delivered) return primary.text || backup.text; console.warn('[Sasquatch] Streaming unavailable — falling back to non-streaming call'); - return callSasquatchAI(messages, maxTokens); + return callSasquatchAI(messages, maxTokens, undefined, handlers.authHeaders); } /** Parse a `{"tool":"name","args":{…}}` tool call out of an AI response, if any. */