#1 Extend now actually extends. useSasquatchPro collected receipts and took the max of (receipt.created_at + tierDays), so buying again before expiry under-delivered. It now stacks every valid proof with renewal semantics (expiry = max(expiry, proofTime) + days), client and server alike. #2 Owner-grant fallback. Pro can now also be granted by an owner-authored NIP-32 kind-1985 label (org.skatespots.pro, p = user, expiry) — so Pro is not solely dependent on the Lightning provider publishing zap receipts. New owner-only GrantProCard (Settings -> Advanced) publishes it to getWriteRelays(); the hook reads via querySkateContent. Protocol: the x-sasquatch-pro payload is now {receipts,grants}; BOTH ai-proxy and the Netlify function accept the new payload and a bare receipt (old APKs). Proxy was deployed first and verified healthy before this commit.
342 lines
12 KiB
TypeScript
342 lines
12 KiB
TypeScript
// 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<Response>;
|
|
}): { 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<string | null> {
|
|
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<number | null> {
|
|
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<string, string> {
|
|
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<string, string>): 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<string, number[]>();
|
|
|
|
async function handleStatus(headers: Record<string, string>): Promise<Response> {
|
|
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<string, string>): Promise<Response> {
|
|
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<Response> {
|
|
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})`);
|