/** * 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 and reused for its whole validity window — * persisted to localStorage so an interactive signer (Amber) does not pop again * on every app load. (It expires with the 12 h window the proxy enforces.) */ import type { NostrEvent } from '@nostrify/nostrify'; import { sasquatchAiBase } from '@/lib/sasquatchAi'; import type { ProProof } from '@/lib/sasquatchPro'; type SignEvent = (event: { kind: number; created_at: number; tags: string[][]; content: string; }) => Promise; let cachedAuthHeader: string | null = null; let cachedAuthPubkey: string | null = null; /** Persisted NIP-98 auth (keyed to the pubkey, valid while inside the window). */ const AUTH_STORAGE_KEY = 'skatespots:sasquatch-nip98'; /** Reuse for 11 h to stay safely inside the proxy's 12 h acceptance window. */ const AUTH_MAX_AGE_SECONDS = 11 * 3600; function loadStoredAuth(pubkey: string): string | null { try { const raw = localStorage.getItem(AUTH_STORAGE_KEY); if (!raw) return null; const parsed = JSON.parse(raw) as { pubkey?: string; header?: string; createdAt?: number }; if (parsed?.pubkey !== pubkey || !parsed.header || !parsed.createdAt) return null; if (Math.abs(Date.now() / 1000 - parsed.createdAt) > AUTH_MAX_AGE_SECONDS) return null; return parsed.header; } catch { return null; } } function storeAuth(pubkey: string, header: string, createdAt: number): void { try { localStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify({ pubkey, header, createdAt })); } catch { /* private mode — the in-memory cache still works for this session */ } } 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; /** Zap receipts + owner grants proving Pro (see useSasquatchPro). */ proof?: ProProof | null; }): Promise> { const { pubkey, signEvent, proof } = opts; const headers: Record = {}; if (!pubkey || !signEvent) return headers; if (cachedAuthPubkey !== pubkey || !cachedAuthHeader) { // Reuse a stored signature first — otherwise Amber pops on every app load // for a signer that would happily reuse the same NIP-98 for 12 h. const stored = loadStoredAuth(pubkey); if (stored) { cachedAuthHeader = stored; cachedAuthPubkey = pubkey; } else { const createdAt = Math.floor(Date.now() / 1000); const event = await signEvent({ kind: 27235, created_at: createdAt, tags: [ ['u', sasquatchAiBase()], ['method', 'POST'], ], content: '', }); cachedAuthHeader = toBase64(JSON.stringify(event)); cachedAuthPubkey = pubkey; storeAuth(pubkey, cachedAuthHeader, createdAt); } } headers['x-sasquatch-auth'] = cachedAuthHeader; // Send ALL proof so the proxy can stack it the same way the client does. if (proof && (proof.receipts.length > 0 || proof.grants.length > 0)) { headers['x-sasquatch-pro'] = toBase64( JSON.stringify({ receipts: proof.receipts, grants: proof.grants }), ); } return headers; }