From ec06a63b01b370f1554e8157736050777284c3fd Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Mon, 28 Sep 2026 13:32:05 +0700 Subject: [PATCH] feat(ai-proxy): run the AI proxy on the CVM host, ready to switch over MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same proxy as netlify/functions/ai.mjs, rebuilt as a standalone Bun service on the CVM host so AI calls stop spending Netlify compute/bandwidth credits and the sats-paid Routstr key lives on our box. - ai-proxy/server.ts: Bun.serve on 127.0.0.1:8787 — NIP-98 + Pro gate, model allowlist, per-IP throttle, /api/ai + /api/ai/status. Own tsconfig (typecheck:ai in npm test); eslint-ignored like cvm-server. - ai-proxy/{package.json,.env.example,skatespots-ai.service,Caddyfile,DEPLOY.md}. - scripts/deploy-ai-proxy.sh (npm run deploy:ai): scp + bun install + unit + restart, mirroring deploy-cvm.sh. - Client: one place decides the AI base, src/lib/sasquatchAi.ts (also the NIP-98 u tag). Defaults unchanged (/api/ai on Netlify); set VITE_SASQUATCH_AI_BASE to move it. Fully reversible. Deployed and verified on the host: /api/ai/status returns the live Routstr balance. Public TLS (DNS + Caddy) is the remaining step before switching the app over, so no client behaviour changes yet. --- AGENTS.md | 17 ++ ai-proxy/.env.example | 14 ++ ai-proxy/Caddyfile | 18 ++ ai-proxy/DEPLOY.md | 73 ++++++++ ai-proxy/package.json | 12 ++ ai-proxy/server.ts | 282 ++++++++++++++++++++++++++++++ ai-proxy/skatespots-ai.service | 28 +++ ai-proxy/tsconfig.json | 15 ++ eslint.config.js | 2 + package.json | 5 +- scripts/deploy-ai-proxy.sh | 59 +++++++ src/hooks/useSasquatchAiStatus.ts | 6 +- src/lib/proAuth.ts | 3 +- src/lib/sasquatchAi.ts | 30 ++++ src/lib/sasquatchBrain.ts | 20 +-- 15 files changed, 566 insertions(+), 18 deletions(-) create mode 100644 ai-proxy/.env.example create mode 100644 ai-proxy/Caddyfile create mode 100644 ai-proxy/DEPLOY.md create mode 100644 ai-proxy/package.json create mode 100644 ai-proxy/server.ts create mode 100644 ai-proxy/skatespots-ai.service create mode 100644 ai-proxy/tsconfig.json create mode 100755 scripts/deploy-ai-proxy.sh create mode 100644 src/lib/sasquatchAi.ts diff --git a/AGENTS.md b/AGENTS.md index 340d4fe..1b9fb9f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -557,6 +557,23 @@ session with **no other memory of this project**. So: host costs a moment, not a blank spot. Blossom is content-addressed, so the mirror URL is derived from the sha256 in the path (handles `/` and `/.`). +- **AI proxy on our own host (`ai-proxy/`, 2026-09-28):** the same proxy the + Netlify Function runs, rebuilt as a standalone **Bun** service on the CVM host so + AI calls stop spending Netlify compute/bandwidth credits and the Routstr key + lives on our box. `ai-proxy/server.ts` (`Bun.serve` on 127.0.0.1:8787; NIP-98 + + Pro gate + model allowlist + per-IP throttle), deployed with **`npm run deploy:ai`** + (`scripts/deploy-ai-proxy.sh`) to `/home/ubuntu/skatespots-ai`, systemd unit + `skatespots-ai`, type-checked by `ai-proxy/tsconfig.json` (`typecheck:ai`, wired + into `npm test`); `ai-proxy/` is eslint-ignored like `cvm-server/`. Env + (`SASQUATCH_*`) copied from Netlify env to `~/skatespots-ai/.env` (600); + **verified live** (`POST /api/ai/status` → the real Routstr balance). The client + picks the base in **one place — `src/lib/sasquatchAi.ts`** (`sasquatchAiBase()`, + also used for the NIP-98 `u` tag): set + `VITE_SASQUATCH_AI_BASE=https://ai.skate-spots.org/api/ai` to switch the app + over; unset keeps the Netlify Function (fully reversible). ⚠️ **Not switched + over yet** — still needs a DNS name → the CVM host + **Caddy** for TLS + (`ai-proxy/Caddyfile`, `ai-proxy/DEPLOY.md`); until then the app calls `/api/ai` + on Netlify. - **Mints directory**: `/mints` page lists Cashu mints from the community **MintRadar** project. MintRadar has **no CORS**, so it's fetched same-origin at `/api/mints/*` — prod proxies via `public/_redirects` (`/api/mints/* → diff --git a/ai-proxy/.env.example b/ai-proxy/.env.example new file mode 100644 index 0000000..eab8392 --- /dev/null +++ b/ai-proxy/.env.example @@ -0,0 +1,14 @@ +# SkateSpots AI proxy — environment (copy to .env on the CVM host; never commit). +# +# The upstream is OpenAI-compatible. For sats-paid inference point it at a Routstr +# node (NOT the aggregator api.routstr.com, which is down). +SASQUATCH_AI_BASE=https://routstr.githappens.space/v1 +SASQUATCH_AI_KEY= +# Comma-separated model allowlist — must be models the node actually serves. +SASQUATCH_MODELS= +# 1 = require a NIP-98 identity plus an active Pro pass (platform owners bypass). +SASQUATCH_REQUIRE_PRO=1 + +# Bind to localhost only; Caddy terminates TLS and reverse-proxies here. +AI_PROXY_HOST=127.0.0.1 +AI_PROXY_PORT=8787 diff --git a/ai-proxy/Caddyfile b/ai-proxy/Caddyfile new file mode 100644 index 0000000..04cba05 --- /dev/null +++ b/ai-proxy/Caddyfile @@ -0,0 +1,18 @@ +# Caddyfile for the AI proxy host. +# +# Replace the site address with the DNS name you point at this server (for +# example ai.skate-spots.org). Caddy obtains a Let's Encrypt certificate +# automatically via the HTTP-01 challenge, so ports 80 and 443 must be reachable +# from the internet (open them in the provider firewall too, not just ufw). +# +# Install + run: +# sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl +# curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg +# curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list +# sudo apt update && sudo apt install -y caddy +# sudo cp Caddyfile /etc/caddy/Caddyfile && sudo systemctl reload caddy + +ai.skate-spots.org { + encode gzip + reverse_proxy 127.0.0.1:8787 +} diff --git a/ai-proxy/DEPLOY.md b/ai-proxy/DEPLOY.md new file mode 100644 index 0000000..13fe19f --- /dev/null +++ b/ai-proxy/DEPLOY.md @@ -0,0 +1,73 @@ +# SkateSpots AI proxy — placement on the CVM host + +Why: the Netlify Function (`netlify/functions/ai.mjs`) works, but every AI call +spends Netlify **compute + response bandwidth** credits. This service is the same +proxy running on the CVM host we already own, so AI traffic never touches Netlify +and the Routstr key lives on our box. + +``` +browser / APK ──HTTPS──▶ Caddy (TLS) ──http──▶ 127.0.0.1:8787 (Bun) + │ + └──HTTPS──▶ Routstr node (sats) +``` + +## What's needed (once) + +1. **A hostname** pointing at the CVM host, e.g. `ai.skate-spots.org` → the + host's public IPv4. Add it wherever `skate-spots.org` DNS is managed. +2. **Ports 80 + 443 open** to the internet (firewall at the provider; `ufw` is + inactive on the host). +3. **Caddy** for automatic TLS (see `Caddyfile` for the install snippet). + +## Deploy + +```bash +npm run deploy:ai # uploads server.ts + package.json + unit, installs, restarts +bash scripts/deploy-ai-proxy.sh --logs +``` + +The **first** deploy needs `/home/ubuntu/skatespots-ai/.env` to exist (the unit +reads it). Create it from `ai-proxy/.env.example` with the real Routstr key — that +key is the one currently in Netlify env `SASQUATCH_AI_KEY`: + +```bash +ssh 'cat > ~/skatespots-ai/.env' <<'EOF' +SASQUATCH_AI_BASE=https://routstr.githappens.space/v1 +SASQUATCH_AI_KEY= +SASQUATCH_MODELS= +SASQUATCH_REQUIRE_PRO=1 +AI_PROXY_HOST=127.0.0.1 +AI_PROXY_PORT=8787 +EOF +ssh 'chmod 600 ~/skatespots-ai/.env' +``` + +## Point the app at it + +The client resolves its AI base in one place (`src/lib/sasquatchAi.ts`). Set the +build-time override and redeploy the web app: + +``` +VITE_SASQUATCH_AI_BASE=https://ai.skate-spots.org/api/ai +``` + +Leave it unset and the app keeps using `/api/ai` (the Netlify Function), so the +move is reversible and can be rolled out gradually. The proxy's CORS allowlist +already includes `https://skate-spots.org` and the local/Capacitor origins. + +## Verify + +```bash +# from anywhere, once TLS is up: +curl -s -X POST https://ai.skate-spots.org/api/ai/status +# → {"configured":true,...} (owner balance; public on purpose) +``` + +## Notes + +- `SASQUATCH_REQUIRE_PRO=1` keeps the Pro gate (owner pubkeys bypass). A client + without a valid NIP-98 header gets 401; without an active Pro pass, 402. +- The proxy is per-IP throttled at 20 req/min. It's a single long-lived process, + so the throttle actually holds (unlike the serverless version). +- `netlify/functions/ai.mjs` stays in the repo as the fallback path; nothing + breaks if this service is down and the client is still pointed at `/api/ai`. diff --git a/ai-proxy/package.json b/ai-proxy/package.json new file mode 100644 index 0000000..bec148e --- /dev/null +++ b/ai-proxy/package.json @@ -0,0 +1,12 @@ +{ + "name": "skatespots-ai-proxy", + "private": true, + "type": "module", + "scripts": { + "start": "bun run server.ts", + "dev": "bun --watch run server.ts" + }, + "dependencies": { + "nostr-tools": "^2.13.0" + } +} diff --git a/ai-proxy/server.ts b/ai-proxy/server.ts new file mode 100644 index 0000000..ce36bab --- /dev/null +++ b/ai-proxy/server.ts @@ -0,0 +1,282 @@ +// 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; + }): { 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 }, +]; + +let providerPubkeyCache: string | null | undefined; +async function getProProviderPubkey(): Promise { + 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; + } +} + +/** Verify a Pro pass (provider-authored zap receipt bound to the caller). */ +async function verifyPro(receiptHeader: string | null, userPubkey: string): Promise { + 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: string[]) => 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', + 'http://localhost:8080', + 'http://localhost:5173', + 'capacitor://localhost', + 'http://localhost', + 'https://localhost', +]); +function corsHeaders(origin: string): Record { + 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): 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(); + +async function handleStatus(headers: Record): Promise { + 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): Promise { + 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 { + 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})`); diff --git a/ai-proxy/skatespots-ai.service b/ai-proxy/skatespots-ai.service new file mode 100644 index 0000000..4cb8d23 --- /dev/null +++ b/ai-proxy/skatespots-ai.service @@ -0,0 +1,28 @@ +# ───────────────────────────────────────────────────────────────────────────── +# systemd unit for the SkateSpots AI proxy (Bun, on the CVM host). +# Installed by scripts/deploy-ai-proxy.sh — do not hand-edit on the host. +# ───────────────────────────────────────────────────────────────────────────── + +[Unit] +Description=SkateSpots AI proxy (Routstr) +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=ubuntu +WorkingDirectory=/home/ubuntu/skatespots-ai +EnvironmentFile=/home/ubuntu/skatespots-ai/.env +ExecStart=/home/ubuntu/.bun/bin/bun run server.ts +Restart=always +RestartSec=5 +StandardOutput=journal +StandardError=journal +SyslogIdentifier=skatespots-ai + +# Basic hardening — mirrors the CVM unit (bun stays happy; binds localhost only). +NoNewPrivileges=true +PrivateTmp=true + +[Install] +WantedBy=multi-user.target diff --git a/ai-proxy/tsconfig.json b/ai-proxy/tsconfig.json new file mode 100644 index 0000000..c6107b7 --- /dev/null +++ b/ai-proxy/tsconfig.json @@ -0,0 +1,15 @@ +{ + // Type-checks the standalone Bun AI proxy. Separate from the app's root + // tsconfig (which only includes `src`) and run by `npm test` via `typecheck:ai`. + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ESNext"], + "types": ["node"], + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["server.ts"] +} diff --git a/eslint.config.js b/eslint.config.js index 8f22375..b14f946 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -22,6 +22,8 @@ export default tseslint.config( // `typecheck:cvm` script, wired into `npm test`), not by the web ESLint // config (it leans on `any` and has no React rules). "cvm-server", + // Standalone Bun AI proxy — same deal: own tsconfig (`typecheck:ai`). + "ai-proxy", // Static utility HTML (icon generators, sticker sheets, QR pages) that // are intentionally minimal and don't need app-level meta/OG tags. "public/**/*.html", diff --git a/package.json b/package.json index a8fa53c..ddc345b 100644 --- a/package.json +++ b/package.json @@ -8,9 +8,12 @@ "dev": "npm i --silent && vite", "build": "npm i --silent && vite build -l error && cp dist/index.html dist/404.html && echo 'Project built successfully!'", "deploy:cvm": "bash scripts/deploy-cvm.sh", + "deploy:ai": "bash scripts/deploy-ai-proxy.sh", "typecheck:cvm": "tsc -p cvm-server/tsconfig.json", + "typecheck:ai": "tsc -p ai-proxy/tsconfig.json", "check:cvm": "bun build cvm-server/server.ts --target=bun --outfile=/tmp/cvm-check.js", - "test": "npm i --silent && tsc --noEmit && tsc -p cvm-server/tsconfig.json && eslint && vitest run --reporter=dot --silent && vite build -l error && cp dist/index.html dist/404.html && echo 'All tests passed!'", + "check:ai": "bun build ai-proxy/server.ts --target=bun --outfile=/tmp/ai-check.js", + "test": "npm i --silent && tsc --noEmit && tsc -p cvm-server/tsconfig.json && tsc -p ai-proxy/tsconfig.json && eslint && vitest run --reporter=dot --silent && vite build -l error && cp dist/index.html dist/404.html && echo 'All tests passed!'", "deploy": "npm i --silent && npm run build && npx -y nostr-deploy-cli deploy --skip-setup", "android:init": "npm run android:build:apk && npx cap add android && npx cap sync android && node scripts/inject-amber-queries.mjs && node scripts/inject-main-activity.mjs && node scripts/inject-network-security.mjs && node scripts/inject-signing-config.mjs", "android:sync": "npm run android:build:apk && npx cap sync android && node scripts/sync-android-version.mjs && node scripts/inject-amber-queries.mjs && node scripts/inject-main-activity.mjs && node scripts/inject-network-security.mjs && node scripts/inject-signing-config.mjs", diff --git a/scripts/deploy-ai-proxy.sh b/scripts/deploy-ai-proxy.sh new file mode 100755 index 0000000..207e745 --- /dev/null +++ b/scripts/deploy-ai-proxy.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# +# Deploy the SkateSpots AI proxy to the CVM host (same box as the CVM server). +# +# It is the same class of service as deploy-cvm.sh: a Bun process under systemd, +# reachable only via Caddy (TLS) on the public side. Config/host come from +# ~/.config/skatespots/cvm.env (never committed). +# +# npm run deploy:ai # or: bash scripts/deploy-ai-proxy.sh +# bash scripts/deploy-ai-proxy.sh --logs +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +CONF="${HOME}/.config/skatespots/cvm.env" +if [[ -f "$CONF" ]]; then + # shellcheck disable=SC1090 + source "$CONF" +fi + +: "${CVM_HOST:?Set CVM_HOST (e.g. in ~/.config/skatespots/cvm.env) — the host is never committed}" +CVM_SSH_KEY="${CVM_SSH_KEY:-$HOME/.ssh/skatespots-new}" +AI_DIR="${AI_DIR:-/home/ubuntu/skatespots-ai}" +AI_SERVICE="${AI_SERVICE:-skatespots-ai}" + +SSH=(ssh -i "$CVM_SSH_KEY" -o BatchMode=yes -o ConnectTimeout=12 "$CVM_HOST") + +if [[ "${1:-}" == "--logs" ]]; then + "${SSH[@]}" "journalctl -u $AI_SERVICE -n 40 --no-pager" + exit 0 +fi + +echo "→ Ensuring $AI_DIR exists" +"${SSH[@]}" "mkdir -p $AI_DIR" + +echo "→ Uploading ai-proxy/{server.ts,package.json,skatespots-ai.service}" +scp -i "$CVM_SSH_KEY" -o BatchMode=yes \ + "$REPO_ROOT/ai-proxy/server.ts" \ + "$REPO_ROOT/ai-proxy/package.json" \ + "$REPO_ROOT/ai-proxy/skatespots-ai.service" \ + "$CVM_HOST:$AI_DIR/" + +echo "→ bun install" +"${SSH[@]}" "cd $AI_DIR && ~/.bun/bin/bun install --silent 2>&1 | tail -3 || true" + +echo "→ Installing the unit and (re)starting $AI_SERVICE" +"${SSH[@]}" "sudo cp $AI_DIR/skatespots-ai.service /etc/systemd/system/ \ + && sudo systemctl daemon-reload \ + && sudo systemctl enable --now $AI_SERVICE >/dev/null 2>&1 || true; \ + sudo systemctl restart $AI_SERVICE \ + && sleep 2 && echo \"active: \$(systemctl is-active $AI_SERVICE)\"" + +echo "→ Local health check (through the service, on the host)" +"${SSH[@]}" "curl -s -o /dev/null -w 'status endpoint: %{http_code}\n' -X POST http://127.0.0.1:8787/api/ai/status || true" + +echo "→ Recent log:" +"${SSH[@]}" "journalctl -u $AI_SERVICE -n 15 --no-pager | tail -6" + +echo "✅ AI proxy deployed. Public TLS is served by Caddy — see ai-proxy/DEPLOY.md." diff --git a/src/hooks/useSasquatchAiStatus.ts b/src/hooks/useSasquatchAiStatus.ts index fd9d120..130f28b 100644 --- a/src/hooks/useSasquatchAiStatus.ts +++ b/src/hooks/useSasquatchAiStatus.ts @@ -1,7 +1,7 @@ import { useQuery } from '@tanstack/react-query'; -import { Capacitor } from '@capacitor/core'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { PLATFORM_OWNER_PUBKEYS } from '@/hooks/useListingRestrictions'; +import { sasquatchAiStatusUrl } from '@/lib/sasquatchAi'; /** Report shape from the proxy's owner-only `/api/ai/status`. */ export interface SasquatchAiStatus { @@ -11,9 +11,7 @@ export interface SasquatchAiStatus { provider?: string; } -const AI_STATUS_URL = Capacitor.isNativePlatform() - ? 'https://skate-spots.org/api/ai/status' - : '/api/ai/status'; +const AI_STATUS_URL = sasquatchAiStatusUrl(); /** * Owner-only: the Sasquatch AI (upstream) account balance, so the operator can be diff --git a/src/lib/proAuth.ts b/src/lib/proAuth.ts index cdf78ca..6afbfe2 100644 --- a/src/lib/proAuth.ts +++ b/src/lib/proAuth.ts @@ -10,6 +10,7 @@ * signer (Amber) doesn't pop on every message. */ import type { NostrEvent } from '@nostrify/nostrify'; +import { sasquatchAiBase } from '@/lib/sasquatchAi'; type SignEvent = (event: { kind: number; @@ -40,7 +41,7 @@ export async function buildProAuthHeaders(opts: { kind: 27235, created_at: Math.floor(Date.now() / 1000), tags: [ - ['u', 'https://skate-spots.org/api/ai'], + ['u', sasquatchAiBase()], ['method', 'POST'], ], content: '', diff --git a/src/lib/sasquatchAi.ts b/src/lib/sasquatchAi.ts new file mode 100644 index 0000000..88f0ece --- /dev/null +++ b/src/lib/sasquatchAi.ts @@ -0,0 +1,30 @@ +import { isNativePlatform } from '@/lib/platform'; + +/** + * Base URL of the Sasquatch AI proxy — the single place the app decides where AI + * requests go. + * + * Default: the original Netlify Function at `/api/ai` (same-origin on web; the + * absolute production origin on native, which has no same-origin server). + * + * To move AI off Netlify onto our own host (ai-proxy/ on the CVM host), set the + * build-time override: + * VITE_SASQUATCH_AI_BASE=https://ai.skate-spots.org/api/ai + * This is a pure routing switch and trivially reversible: the proxy accepts any + * host — it only checks that a NIP-98 `u` value ends in `/api/ai`. + * + * Returns an absolute URL, so it's safe to use both for `fetch()` and as the + * NIP-98 `u` tag. + */ +export function sasquatchAiBase(): string { + const override = (import.meta.env as Record).VITE_SASQUATCH_AI_BASE; + if (override) return override.replace(/\/+$/, ''); + if (isNativePlatform()) return 'https://skate-spots.org/api/ai'; + const origin = typeof location !== 'undefined' ? location.origin : 'https://skate-spots.org'; + return `${origin}/api/ai`; +} + +/** Owner balance endpoint (`…/api/ai/status`). */ +export function sasquatchAiStatusUrl(): string { + return `${sasquatchAiBase()}/status`; +} diff --git a/src/lib/sasquatchBrain.ts b/src/lib/sasquatchBrain.ts index e894d2f..57db32e 100644 --- a/src/lib/sasquatchBrain.ts +++ b/src/lib/sasquatchBrain.ts @@ -4,20 +4,16 @@ * surface can reuse the same model list + fallback behaviour. */ -import { Capacitor } from '@capacitor/core'; +import { sasquatchAiBase } from '@/lib/sasquatchAi'; // ─── AI models, via our own server-side proxy ──────────────────────────────── -// SECURITY: no API key is ever shipped to the client. Requests go to our Netlify -// Function at `/api/ai`, which runs against Netlify's AI Gateway server-side and -// only allows a curated model list (see netlify/functions/ai.mjs). If the proxy -// is unavailable we fall back to the keyless Pollinations provider below, so -// Sasquatch still works with zero secrets shipped. -// -// The native (Capacitor) build has no same-origin `/api/ai`, so it calls the -// absolute production URL; the web build stays same-origin. -const AI_PROXY_URL = Capacitor.isNativePlatform() - ? 'https://skate-spots.org/api/ai' - : '/api/ai'; +// SECURITY: no API key is ever shipped to the client. Requests go to our proxy +// (`/api/ai`), which holds the Routstr key server-side and only allows a curated +// model list. Where that proxy lives is decided in one place — lib/sasquatchAi.ts +// (the Netlify Function by default, or our own ai-proxy/ service on the CVM host +// via VITE_SASQUATCH_AI_BASE). If it's unavailable we fall back to the keyless +// Pollinations provider below, so Sasquatch still works with zero secrets shipped. +const AI_PROXY_URL = sasquatchAiBase(); // Models Sasquatch requests through the proxy, in preference order. These are // the slugs served by our upstream (a Routstr node — see AGENTS.md), which are