feat(sasquatch-pro): server-side Pro gate on the AI proxy + client credentials

- 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).
This commit is contained in:
SkateSpots 2026-09-27 14:18:33 +07:00
parent 6335965afc
commit ccc04cc172
6 changed files with 246 additions and 19 deletions

View file

@ -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,

View file

@ -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);

View file

@ -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(() => {

View file

@ -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<ProStatus>(() => {
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]);
}

55
src/lib/proAuth.ts Normal file
View file

@ -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<NostrEvent>;
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<Record<string, string>> {
const { pubkey, signEvent, receiptEvent } = opts;
const headers: Record<string, string> = {};
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;
}

View file

@ -64,6 +64,7 @@ async function tryProxy(
messages: ChatTurn[],
maxTokens: number,
remainingMs: () => number,
authHeaders: Record<string, string> = {},
): Promise<string> {
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<string, string> = {},
): Promise<string> {
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<string, string> = {},
): Promise<StreamAttempt> {
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<string, string> } = {},
): Promise<string> {
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. */